Przeglądaj źródła

feat: deploy and roll back governed data production lines

马小龙 3 miesięcy temu
rodzic
commit
4ce301a23c

+ 298 - 1
app/api/data_rules/routes.py

@@ -23,6 +23,7 @@ from app.core.data_rules.contracts import (
     validate_rule_spec,
     validate_standard_spec,
 )
+from app.core.data_rules.deployment import DataFlowDeploymentService
 from app.core.data_rules.production_line import resolve_production_line
 from app.core.data_rules.publication import (
     GenerationReceiptSigner,
@@ -40,7 +41,9 @@ from app.core.data_rules.schema_resolver import (
     Neo4jSchemaMetadataCatalog,
     SchemaResolver,
 )
+from app.core.orchestration.engines.kestra import KestraAdapter
 from app.models.result import failed, success
+from app.runner.auth import TaskTokenIssuer
 
 _VALIDATORS = {
     "rule": (validate_rule_spec, rule_spec_hash),
@@ -97,6 +100,70 @@ def _schema_resolver() -> SchemaResolver:
     return SchemaResolver(catalog or Neo4jSchemaMetadataCatalog(), repository)
 
 
+def _deployment_readiness() -> dict[str, Any]:
+    reasons = []
+    if not current_app.config.get("KESTRA_BASE_URL"):
+        reasons.append("kestra_base_url_missing")
+    if not current_app.config.get("KESTRA_USERNAME"):
+        reasons.append("kestra_username_missing")
+    if not current_app.config.get("KESTRA_PASSWORD"):
+        reasons.append("kestra_password_missing")
+    secret = current_app.config.get("RUNNER_TASK_TOKEN_SECRET")
+    if not isinstance(secret, str) or len(secret.encode("utf-8")) < 32:
+        reasons.append("runner_task_token_secret_invalid")
+    gate_open = bool(
+        current_app.config.get("DATA_FACTORY_ACTIVATION_ENABLED", False)
+    )
+    if not gate_open:
+        reasons.append("post_acceptance_activation_gate_closed")
+    return {
+        "ready": not reasons,
+        "gate_open": gate_open,
+        "reasons": reasons,
+        "server_owned": True,
+    }
+
+
+def _deployment_service() -> DataFlowDeploymentService:
+    configured = current_app.extensions.get("dataflow_deployment_service")
+    if configured is not None:
+        return configured
+    readiness = _deployment_readiness()
+    infrastructure_reasons = [
+        reason
+        for reason in readiness["reasons"]
+        if reason != "post_acceptance_activation_gate_closed"
+    ]
+    if infrastructure_reasons:
+        raise RuntimeError("data factory deployment infrastructure is not ready")
+    engine = KestraAdapter(
+        current_app.config["KESTRA_BASE_URL"],
+        current_app.config["KESTRA_USERNAME"],
+        current_app.config["KESTRA_PASSWORD"],
+        tenant_id=current_app.config.get("KESTRA_TENANT_ID", "main"),
+        timeout=int(
+            current_app.config.get("KESTRA_HTTP_TIMEOUT_SECONDS", 30)
+        ),
+    )
+    service = DataFlowDeploymentService(
+        _repository(),
+        engine=engine,
+        token_issuer=TaskTokenIssuer(
+            current_app.config["RUNNER_TASK_TOKEN_SECRET"]
+        ),
+    )
+    current_app.extensions["dataflow_deployment_service"] = service
+    return service
+
+
+def _activation_gate_ready() -> bool:
+    if current_app.extensions.get("dataflow_deployment_service") is not None:
+        return bool(
+            current_app.config.get("DATA_FACTORY_ACTIVATION_ENABLED", False)
+        )
+    return bool(_deployment_readiness()["ready"])
+
+
 def _receipt_signer() -> GenerationReceiptSigner:
     configured = current_app.extensions.get("generation_receipt_signer")
     if configured is not None:
@@ -201,6 +268,18 @@ def _metadata_hash(value: Any) -> str:
 
 @bp.get("/capabilities")
 def capabilities():
+    readiness = _deployment_readiness()
+    if current_app.extensions.get("dataflow_deployment_service") is not None:
+        readiness = {
+            **readiness,
+            "ready": bool(readiness["gate_open"]),
+            "reasons": [
+                reason
+                for reason in readiness["reasons"]
+                if reason
+                == "post_acceptance_activation_gate_closed"
+            ],
+        }
     return jsonify(
         success(
             {
@@ -210,7 +289,8 @@ def capabilities():
                 "immutable_asset_versions": True,
                 "server_side_publishing": True,
                 "production_line_release": True,
-                "data_factory_activation": False,
+                "data_factory_activation": readiness["ready"],
+                "data_factory_readiness": readiness,
             }
         )
     )
@@ -757,3 +837,220 @@ def release_production_line(dataflow_uid: str):
         db.session.rollback()
         current_app.logger.exception("release production line failed")
         return jsonify(failed("数据生产线发布失败", code=500)), 500
+
+
+@bp.get("/deployments")
+def list_dataflow_deployments():
+    try:
+        if set(request.args) - {"environment"}:
+            raise ValueError("deployment query contains unsupported fields")
+        return jsonify(
+            success(
+                {
+                    "items": _deployment_service().list(
+                        environment=request.args.get("environment") or None
+                    ),
+                    "readiness": _deployment_readiness(),
+                }
+            )
+        )
+    except (TypeError, ValueError):
+        return _bad_request("数据工厂投产查询无效")
+    except RuntimeError:
+        return jsonify(failed("数据工厂投产服务未配置", code=503)), 503
+
+
+@bp.get("/deployments/candidates")
+def list_dataflow_deployment_candidates():
+    try:
+        if set(request.args) != {"environment"}:
+            raise ValueError("candidate environment is required")
+        return jsonify(
+            success(
+                {
+                    "items": _deployment_service().candidates(
+                        environment=request.args["environment"]
+                    )
+                }
+            )
+        )
+    except (TypeError, ValueError):
+        return _bad_request("可入厂生产线查询无效")
+    except RuntimeError:
+        return jsonify(failed("数据工厂投产服务未配置", code=503)), 503
+
+
+@bp.post("/deployments")
+def create_dataflow_deployment():
+    try:
+        body = _closed_body(
+            {
+                "dataflow_version_id",
+                "environment",
+                "binding_snapshot",
+                "schedule_plan",
+                "reason",
+                "idempotency_key",
+                "correlation_id",
+            }
+        )
+        result = _deployment_service().create(
+            body.get("dataflow_version_id"),
+            binding_snapshot=body.get("binding_snapshot"),
+            environment=body.get("environment"),
+            schedule_plan=body.get("schedule_plan"),
+            actor_uid=g.current_user["id"],
+            reason=body.get("reason"),
+            idempotency_key=body.get("idempotency_key"),
+            correlation_id=body.get("correlation_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "数据生产线已进入数据工厂")), 201
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("数据工厂投产定义无效", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("数据工厂投产服务未配置", code=503)), 503
+    except Exception:
+        db.session.rollback()
+        current_app.logger.exception("create dataflow deployment failed")
+        return jsonify(failed("数据工厂投产创建失败", code=500)), 500
+
+
+def _deployment_action_body(extra: set[str] | None = None):
+    return _closed_body(
+        {
+            "reason",
+            "idempotency_key",
+            "correlation_id",
+            *(extra or set()),
+        }
+    )
+
+
+@bp.post("/deployments/<deployment_id>/deploy-disabled")
+def deploy_dataflow_disabled(deployment_id: str):
+    try:
+        body = _deployment_action_body()
+        result = _deployment_service().deploy_disabled(
+            deployment_id,
+            g.current_user["id"],
+            reason=body.get("reason", "deploy disabled"),
+            idempotency_key=body.get("idempotency_key"),
+            correlation_id=body.get("correlation_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "生产线已禁用部署"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("生产线无法禁用部署", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("调度引擎部署结果异常", code=503)), 503
+
+
+@bp.post("/deployments/<deployment_id>/canary")
+def run_dataflow_canary(deployment_id: str):
+    try:
+        body = _deployment_action_body({"inputs"})
+        result = _deployment_service().run_canary(
+            deployment_id,
+            body.get("inputs", {}),
+            g.current_user["id"],
+            reason=body.get("reason", "trial production"),
+            idempotency_key=body.get("idempotency_key"),
+            correlation_id=body.get("correlation_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "试生产已完成"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("试生产请求无效", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("试生产执行结果异常", code=503)), 503
+
+
+@bp.post("/deployments/<deployment_id>/activate")
+def activate_dataflow_deployment(deployment_id: str):
+    if not _activation_gate_ready():
+        return jsonify(failed("数据工厂激活门禁尚未开放", code=503)), 503
+    try:
+        body = _deployment_action_body({"evidence_id"})
+        result = _deployment_service().activate(
+            deployment_id,
+            body.get("evidence_id"),
+            g.current_user["id"],
+            reason=body.get("reason", "mass production activation"),
+            idempotency_key=body.get("idempotency_key"),
+            correlation_id=body.get("correlation_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "生产线已转入批量生产"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("生产线无法激活", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("生产线激活结果异常", code=503)), 503
+
+
+@bp.post("/deployments/<deployment_id>/rollback")
+def rollback_dataflow_deployment(deployment_id: str):
+    try:
+        body = _deployment_action_body()
+        result = _deployment_service().rollback(
+            deployment_id,
+            g.current_user["id"],
+            reason=body.get("reason", "restore previous production line"),
+            idempotency_key=body.get("idempotency_key"),
+            correlation_id=body.get("correlation_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "已恢复上一条稳定生产线"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("生产线无法回滚", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("生产线回滚结果异常", code=503)), 503
+
+
+def _reconcile_dataflow_deployment(deployment_id: str, action: str):
+    try:
+        body = _closed_body({"idempotency_key"})
+        result = _deployment_service().reconcile(
+            deployment_id,
+            action,
+            g.current_user["id"],
+            idempotency_key=body.get("idempotency_key"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "投产未知结果已完成对账"))
+    except PermissionError:
+        db.session.rollback()
+        return jsonify(failed("权限不足", code=403)), 403
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("投产未知结果无法对账", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("投产对账服务异常", code=503)), 503
+
+
+@bp.post("/deployments/<deployment_id>/reconcile-deploy")
+def reconcile_dataflow_deploy(deployment_id: str):
+    return _reconcile_dataflow_deployment(
+        deployment_id, "deploy_disabled"
+    )
+
+
+@bp.post("/deployments/<deployment_id>/reconcile-activate")
+def reconcile_dataflow_activate(deployment_id: str):
+    return _reconcile_dataflow_deployment(deployment_id, "activate")
+
+
+@bp.post("/deployments/<deployment_id>/reconcile-rollback")
+def reconcile_dataflow_rollback(deployment_id: str):
+    return _reconcile_dataflow_deployment(deployment_id, "rollback")

+ 18 - 0
app/config/config.py

@@ -467,6 +467,24 @@ class BaseConfig:
     N8N_API_KEY = os.environ.get("N8N_API_KEY", "")
     N8N_API_TIMEOUT = int(os.environ.get("N8N_API_TIMEOUT", "30"))
 
+    # Kestra-backed governed Data Factory. Activation remains fail-closed
+    # unless the operator explicitly opens the post-acceptance gate.
+    KESTRA_BASE_URL = os.environ.get(
+        "KESTRA_BASE_URL", "http://127.0.0.1:18080/api/v1"
+    )
+    KESTRA_TENANT_ID = os.environ.get("KESTRA_TENANT_ID", "main")
+    KESTRA_USERNAME = os.environ.get("KESTRA_USERNAME", "")
+    KESTRA_PASSWORD = os.environ.get("KESTRA_PASSWORD", "")
+    KESTRA_HTTP_TIMEOUT_SECONDS = int(
+        os.environ.get("KESTRA_HTTP_TIMEOUT_SECONDS", "30")
+    )
+    RUNNER_TASK_TOKEN_SECRET = os.environ.get(
+        "RUNNER_TASK_TOKEN_SECRET", ""
+    )
+    DATA_FACTORY_ACTIVATION_ENABLED = get_bool_env(
+        "DATA_FACTORY_ACTIVATION_ENABLED", False
+    )
+
     # DataOps 平台 API 基础 URL(用于 n8n 工作流回调等)
     API_BASE_URL = os.environ.get("API_BASE_URL", "http://127.0.0.1:5500/api")
 

+ 1055 - 0
app/core/data_rules/deployment.py

@@ -0,0 +1,1055 @@
+"""Governed Data Factory deployment lifecycle.
+
+This service is the boundary between immutable production-line semantics and
+environment-specific infrastructure.  It never accepts executable source,
+recompiles a rule, or mutates a released package.
+"""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import re
+import time
+from collections.abc import Callable, Mapping
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+import requests
+
+from app.core.common.identifiers import ensure_governance_uid, new_governance_uid
+from app.core.orchestration.compilers.kestra import compile_kestra_flow
+from app.core.orchestration.spec import validate_schedule_plan
+
+ENVIRONMENTS = frozenset({"development", "test", "production"})
+TRANSITIONS = {
+    "draft": frozenset({"disabled", "failed"}),
+    "disabled": frozenset({"canary", "failed"}),
+    "canary": frozenset({"active", "failed", "rolled_back"}),
+    "active": frozenset({"superseded", "rolled_back", "failed"}),
+    "failed": frozenset({"disabled", "rolled_back"}),
+    "superseded": frozenset({"rolled_back", "active"}),
+    "rolled_back": frozenset(),
+}
+_FORBIDDEN_INPUT_KEYS = frozenset(
+    {
+        "sql",
+        "python",
+        "script",
+        "code",
+        "yaml",
+        "workflow_spec",
+        "execution_plan",
+        "artifact",
+        "artifact_ref",
+        "password",
+        "secret",
+        "credential",
+        "credentials",
+        "token",
+        "api_key",
+        "connection_string",
+    }
+)
+_BINDING_SIDES = frozenset({"input", "output"})
+_BINDING_FIELDS = frozenset(
+    {
+        "data_source_uid",
+        "object_kind",
+        "object_ref",
+        "schema_snapshot_id",
+        "schema_hash",
+        "binding_hash",
+        "access_mode",
+        "write_mode",
+        "dialect",
+        "binding_id",
+        "logical_ref",
+    }
+)
+
+
+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, limit: int) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    normalized = value.strip()
+    if len(normalized) > limit:
+        raise ValueError(f"{label} exceeds {limit} characters")
+    return normalized
+
+
+def _digest(value: Any, label: str) -> str:
+    normalized = str(value or "").strip().lower()
+    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:
+    encoded = json.dumps(
+        value,
+        sort_keys=True,
+        separators=(",", ":"),
+        ensure_ascii=False,
+    ).encode("utf-8")
+    return hashlib.sha256(encoded).hexdigest()
+
+
+def _timestamp(value: Any, label: str) -> datetime:
+    if isinstance(value, datetime):
+        parsed = value
+    elif isinstance(value, str):
+        source = value[:-1] + "+00:00" if value.endswith("Z") else value
+        try:
+            parsed = datetime.fromisoformat(source)
+        except ValueError as exc:
+            raise ValueError(f"{label} is invalid") from exc
+    else:
+        raise ValueError(f"{label} is invalid")
+    if parsed.tzinfo is None:
+        raise ValueError(f"{label} must include a timezone")
+    return parsed.astimezone(UTC)
+
+
+def _closed_tree(value: Any) -> None:
+    if isinstance(value, Mapping):
+        for key, item in value.items():
+            normalized = str(key).strip().lower()
+            if normalized in _FORBIDDEN_INPUT_KEYS:
+                if normalized in {
+                    "password",
+                    "secret",
+                    "credential",
+                    "credentials",
+                    "token",
+                    "api_key",
+                    "connection_string",
+                }:
+                    raise ValueError("deployment credentials are not accepted")
+                raise ValueError("inline executable semantics are not accepted")
+            _closed_tree(item)
+    elif isinstance(value, list):
+        for item in value:
+            _closed_tree(item)
+
+
+def _binding_snapshot(value: Any) -> dict[str, Any]:
+    if not isinstance(value, Mapping) or set(value) != _BINDING_SIDES:
+        raise ValueError("binding_snapshot must contain input and output")
+    result: dict[str, Any] = {}
+    for side in ("input", "output"):
+        raw = value[side]
+        if not isinstance(raw, Mapping):
+            raise ValueError(f"{side} binding must be an object")
+        unknown = set(raw) - _BINDING_FIELDS
+        if unknown:
+            raise ValueError(
+                f"{side} binding contains unsupported fields: "
+                + ", ".join(sorted(unknown))
+            )
+        _closed_tree(raw)
+        required = {
+            "data_source_uid",
+            "object_kind",
+            "object_ref",
+            "schema_snapshot_id",
+            "schema_hash",
+            "binding_hash",
+            "access_mode",
+            "dialect",
+        }
+        if not required.issubset(raw):
+            raise ValueError(f"{side} binding is incomplete")
+        item = {
+            "data_source_uid": _uid(
+                raw["data_source_uid"], f"{side} data_source_uid"
+            ),
+            "object_kind": _text(
+                raw["object_kind"], f"{side} object_kind", 40
+            ),
+            "object_ref": _text(raw["object_ref"], f"{side} object_ref", 500),
+            "schema_snapshot_id": _uid(
+                raw["schema_snapshot_id"], f"{side} schema_snapshot_id"
+            ),
+            "schema_hash": _digest(
+                raw["schema_hash"], f"{side} schema_hash"
+            ),
+            "binding_hash": _digest(
+                raw["binding_hash"], f"{side} binding_hash"
+            ),
+            "access_mode": _text(
+                raw["access_mode"], f"{side} access_mode", 20
+            ),
+            "dialect": _text(raw["dialect"], f"{side} dialect", 40).lower(),
+        }
+        if raw.get("binding_id") is not None:
+            item["binding_id"] = _uid(
+                raw["binding_id"], f"{side} binding_id"
+            )
+        if raw.get("logical_ref") is not None:
+            item["logical_ref"] = _text(
+                raw["logical_ref"], f"{side} logical_ref", 500
+            )
+        if "write_mode" in raw and raw["write_mode"] is not None:
+            item["write_mode"] = _text(
+                raw["write_mode"], f"{side} write_mode", 40
+            )
+        result[side] = item
+    if result["input"]["access_mode"] != "read":
+        raise ValueError("input binding access_mode must be read")
+    if result["output"]["access_mode"] != "write":
+        raise ValueError("output binding access_mode must be write")
+    if result["input"].get("write_mode"):
+        raise ValueError("input binding cannot declare write_mode")
+    if not result["output"].get("write_mode"):
+        raise ValueError("output binding requires write_mode")
+    return result
+
+
+def _safe_receipt(value: Any) -> dict[str, Any]:
+    if not isinstance(value, Mapping):
+        return {}
+    allowed = {"id", "revision", "status", "uid", "namespace", "flowId"}
+    return {
+        str(key): copy.deepcopy(item)
+        for key, item in value.items()
+        if key in allowed and isinstance(item, (str, int, float, bool, type(None)))
+    }
+
+
+class DataFlowDeploymentService:
+    """Deploy one released production line through fail-closed transitions."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        engine,
+        compiler: Callable[..., Any] | None = None,
+        token_issuer=None,
+        clock: Callable[[], datetime] | None = None,
+        canary_ttl_seconds: int = 900,
+        canary_timeout_seconds: int = 60,
+        poll_interval_seconds: float = 1.0,
+        monotonic: Callable[[], float] | None = None,
+        sleeper: Callable[[float], None] | None = None,
+    ):
+        if engine is None:
+            raise RuntimeError("deployment engine is not configured")
+        self.repository = repository
+        self.engine = engine
+        self.compiler = compiler or compile_kestra_flow
+        self.token_issuer = token_issuer
+        self.clock = clock or (lambda: datetime.now(UTC))
+        self.monotonic = monotonic or time.monotonic
+        self.sleeper = sleeper or time.sleep
+        if canary_ttl_seconds < 60 or canary_ttl_seconds > 3600:
+            raise ValueError("canary evidence ttl is outside policy")
+        self.canary_ttl_seconds = canary_ttl_seconds
+        if canary_timeout_seconds < 1 or canary_timeout_seconds > 900:
+            raise ValueError("canary timeout is outside policy")
+        if poll_interval_seconds <= 0 or poll_interval_seconds > 10:
+            raise ValueError("canary poll interval is outside policy")
+        self.canary_timeout_seconds = canary_timeout_seconds
+        self.poll_interval_seconds = poll_interval_seconds
+
+    @staticmethod
+    def _operation_key(value: Any) -> str:
+        return _text(value, "idempotency_key", 200)
+
+    @staticmethod
+    def _correlation(value: Any) -> str:
+        return _uid(value or new_governance_uid(), "correlation_id")
+
+    def create(
+        self,
+        dataflow_version_id: str,
+        *,
+        binding_snapshot: dict[str, Any],
+        environment: str,
+        schedule_plan: dict[str, Any],
+        actor_uid: str,
+        reason: str,
+        idempotency_key: str,
+        correlation_id: str | None = None,
+    ) -> dict[str, Any]:
+        version_id = _uid(dataflow_version_id, "dataflow_version_id")
+        actor = _uid(actor_uid, "actor_uid")
+        if environment not in ENVIRONMENTS:
+            raise ValueError("deployment environment is invalid")
+        key = self._operation_key(idempotency_key)
+        why = _text(reason, "reason", 1000)
+        correlation = self._correlation(correlation_id)
+        requested_binding = binding_snapshot
+        schedule = validate_schedule_plan(schedule_plan)
+        release = self.repository.load_deployable_release(
+            version_id, environment=environment
+        )
+        if (
+            not isinstance(release, dict)
+            or release.get("status") != "released"
+            or str(release.get("id")) != version_id
+        ):
+            raise ValueError("only a released dataflow version may be deployed")
+        package = release.get("package")
+        if not isinstance(package, dict):
+            raise ValueError("released production-line package is missing")
+        package_hash = _digest(release.get("package_hash"), "package_hash")
+        if package.get("package_hash") != package_hash:
+            raise ValueError("released production-line package has drifted")
+        workflow_spec = package.get("workflow_spec")
+        if not isinstance(workflow_spec, dict):
+            raise ValueError("released workflow spec is missing")
+        if workflow_spec.get("dataflow_uid") != package.get("dataflow_uid"):
+            raise ValueError("released workflow identity has drifted")
+        physical_hashes = sorted(
+            {
+                _digest(item, "physical_plan_hash")
+                for item in release.get("physical_plan_hashes", [])
+            }
+        )
+        if not physical_hashes:
+            raise ValueError("published physical execution plans are required")
+        schema_snapshots = release.get("schema_snapshots")
+        if not isinstance(schema_snapshots, dict):
+            raise ValueError("server-owned schema snapshots are required")
+        canonical_binding = _binding_snapshot(release.get("binding_snapshot"))
+        if (
+            isinstance(requested_binding, Mapping)
+            and set(requested_binding) == {"input", "output"}
+            and all(
+                isinstance(requested_binding[side], Mapping)
+                and set(requested_binding[side]) == {"binding_id"}
+                for side in ("input", "output")
+            )
+        ):
+            if any(
+                _uid(
+                    requested_binding[side]["binding_id"],
+                    f"{side} binding_id",
+                )
+                != canonical_binding[side].get("binding_id")
+                for side in ("input", "output")
+            ):
+                raise ValueError("deployment binding selection has drifted")
+        else:
+            requested = _binding_snapshot(requested_binding)
+            if requested != canonical_binding:
+                raise ValueError("deployment binding selection has drifted")
+        binding = canonical_binding
+        for side in ("input", "output"):
+            canonical = schema_snapshots.get(side)
+            if (
+                not isinstance(canonical, dict)
+                or binding[side]["schema_snapshot_id"] != canonical.get("id")
+                or binding[side]["schema_hash"] != canonical.get("schema_hash")
+            ):
+                raise ValueError("deployment binding schema has drifted")
+        snapshot = {
+            "dataflow_version_id": version_id,
+            "version_no": int(release.get("version_no") or 0),
+            "dataflow_uid": _uid(package.get("dataflow_uid"), "dataflow_uid"),
+            "environment": environment,
+            "package": copy.deepcopy(package),
+            "package_hash": package_hash,
+            "standard_version_ids": [
+                _uid(item, "standard_version_id")
+                for item in package.get("standard_version_ids", [])
+            ],
+            "rule_version_ids": [
+                _uid(item, "rule_version_id")
+                for item in package.get("rule_version_ids", [])
+            ],
+            "binding_snapshot": binding,
+            "binding_hash": _canonical_hash(binding),
+            "schema_snapshots": copy.deepcopy(schema_snapshots),
+            "schema_snapshot_hash": _canonical_hash(schema_snapshots),
+            "physical_plan_hashes": physical_hashes,
+            "workflow_spec": copy.deepcopy(workflow_spec),
+            "workflow_spec_hash": _canonical_hash(workflow_spec),
+            "schedule_snapshot": schedule,
+            "schedule_hash": _canonical_hash(schedule),
+            "actor_uid": actor,
+            "reason": why,
+            "idempotency_key": key,
+            "correlation_id": correlation,
+            "preparatory_deployment_id": release.get(
+                "preparatory_deployment_id"
+            ),
+        }
+        return self.repository.create_deployment(snapshot)
+
+    def _claim(
+        self,
+        deployment_id: str,
+        action: str,
+        actor_uid: str,
+        idempotency_key: str,
+        correlation_id: str | None,
+        reason: str,
+        request_payload: dict[str, Any],
+    ):
+        deployment = self.repository.get_deployment(
+            _uid(deployment_id, "deployment_id")
+        )
+        actor = _uid(actor_uid, "actor_uid")
+        key = self._operation_key(idempotency_key)
+        correlation = self._correlation(correlation_id)
+        claimed, operation = self.repository.claim_operation(
+            deployment["id"],
+            action,
+            key,
+            actor,
+            correlation,
+            reason,
+            request_payload,
+        )
+        if claimed:
+            self._commit()
+        if not claimed:
+            if operation.get("status") == "completed":
+                return deployment, actor, operation, operation.get("result")
+            if operation.get("status") == "claimed":
+                raise ValueError("deployment operation is already in progress")
+            if operation.get("status") == "unknown":
+                raise ValueError(
+                    "deployment operation outcome requires reconciliation"
+                )
+            raise ValueError(
+                "failed deployment operation requires a new idempotency key"
+            )
+        return deployment, actor, operation, None
+
+    def _commit(self) -> None:
+        commit = getattr(self.repository, "commit", None)
+        if commit is not None:
+            commit()
+
+    @staticmethod
+    def _unknown_outcome(exc: Exception) -> bool:
+        if isinstance(exc, (requests.Timeout, requests.ConnectionError)):
+            return True
+        if isinstance(exc, requests.HTTPError):
+            response = getattr(exc, "response", None)
+            return response is None or int(response.status_code) >= 500
+        return False
+
+    def _engine_failure(self, operation: Mapping[str, Any], exc: Exception):
+        if self._unknown_outcome(exc):
+            mark_unknown = getattr(
+                self.repository, "mark_operation_unknown", None
+            )
+            if mark_unknown is not None:
+                mark_unknown(
+                    operation["id"], "deployment_engine_outcome_unknown"
+                )
+            else:
+                self.repository.fail_operation(
+                    operation["id"], "deployment_engine_outcome_unknown"
+                )
+            self._commit()
+            raise RuntimeError(
+                "deployment engine outcome is unknown; reconciliation required"
+            )
+        self.repository.fail_operation(
+            operation["id"], "deployment_engine_failure"
+        )
+        self._commit()
+        raise RuntimeError("deployment engine operation failed")
+
+    def _local_operation_failure(
+        self, operation: Mapping[str, Any], error_code: str
+    ) -> None:
+        self.repository.fail_operation(operation["id"], error_code)
+        self._commit()
+
+    def deploy_disabled(
+        self,
+        deployment_id: str,
+        actor_uid: str,
+        *,
+        idempotency_key: str,
+        reason: str = "deploy disabled",
+        correlation_id: str | None = None,
+    ) -> dict[str, Any]:
+        _text(reason, "reason", 1000)
+        deployment, _actor, operation, replay = self._claim(
+            deployment_id,
+            "deploy_disabled",
+            actor_uid,
+            idempotency_key,
+            correlation_id,
+            reason,
+            {},
+        )
+        if replay is not None:
+            return replay
+        try:
+            if deployment["status"] not in {"draft", "failed"}:
+                raise ValueError(
+                    "deployment is not eligible for disabled deploy"
+                )
+            compiled = self.compiler(
+                deployment["workflow_spec"],
+                deployment["schedule_snapshot"],
+                deployment["environment"],
+                int(deployment.get("version_no") or 1),
+            )
+        except Exception:
+            self._local_operation_failure(
+                operation, "deployment_preflight_failed"
+            )
+            raise
+        if compiled.definition_hash != _canonical_hash(
+            # The compiler owns canonical serialized output; this comparison is
+            # deliberately against its own immutable payload.
+            compiled.yaml
+        ):
+            # Existing compiler hashes bytes, while canonical JSON hashing a
+            # string includes quotes. Preserve its hash but still validate it.
+            _digest(compiled.definition_hash, "engine_definition_hash")
+        try:
+            response = self.engine.deploy_disabled(compiled.yaml)
+        except Exception as exc:
+            self._engine_failure(operation, exc)
+        engine = {
+            "namespace": compiled.namespace,
+            "definition_id": compiled.flow_id,
+            "revision": (
+                response.get("revision")
+                if isinstance(response, Mapping)
+                else None
+            ),
+            "definition_hash": _digest(
+                compiled.definition_hash, "engine_definition_hash"
+            ),
+            "receipt": _safe_receipt(response),
+        }
+        try:
+            result = self.repository.mark_disabled(
+                deployment["id"],
+                deployment["lock_version"],
+                engine,
+                operation["id"],
+            )
+        except Exception:
+            self._local_operation_failure(
+                operation, "disabled_finalize_failed"
+            )
+            raise
+        self._commit()
+        return result
+
+    def run_canary(
+        self,
+        deployment_id: str,
+        inputs: dict[str, Any],
+        actor_uid: str,
+        *,
+        idempotency_key: str,
+        reason: str = "trial production",
+        correlation_id: str | None = None,
+    ) -> dict[str, Any]:
+        _text(reason, "reason", 1000)
+        if not isinstance(inputs, dict) or len(inputs) > 100:
+            raise ValueError("canary inputs must be a bounded object")
+        _closed_tree(inputs)
+        if len(json.dumps(inputs, ensure_ascii=False).encode("utf-8")) > 65536:
+            raise ValueError("canary inputs exceed size limit")
+        deployment, actor, operation, replay = self._claim(
+            deployment_id,
+            "run_canary",
+            actor_uid,
+            idempotency_key,
+            correlation_id,
+            reason,
+            {"inputs_hash": _canonical_hash(inputs)},
+        )
+        if replay is not None:
+            return replay
+        try:
+            if deployment["status"] not in {"disabled", "canary"}:
+                raise ValueError("disabled deployment is required for canary")
+            allowed_inputs = set(
+                (deployment["workflow_spec"].get("parameters") or {}).keys()
+            )
+            unknown = sorted(set(inputs) - allowed_inputs)
+            if unknown:
+                raise ValueError(
+                    "canary inputs contain unknown parameters: "
+                    + ", ".join(unknown)
+                )
+            engine_inputs = copy.deepcopy(inputs)
+            if self.token_issuer is not None:
+                task_tokens = {}
+                for node in deployment["workflow_spec"].get("nodes", []):
+                    task_tokens[node["id"]] = self.token_issuer.issue(
+                        task_uid=new_governance_uid(),
+                        dataflow_uid=deployment["dataflow_uid"],
+                        deployment_id=deployment["id"],
+                        environment=deployment["environment"],
+                        workflow_version=int(
+                            deployment.get("version_no") or 1
+                        ),
+                        correlation_id=operation["correlation_id"],
+                        node=node,
+                        write_authorized=True,
+                    )
+                engine_inputs["dataops_task_tokens"] = task_tokens
+        except Exception:
+            self._local_operation_failure(
+                operation, "canary_preflight_failed"
+            )
+            raise
+        try:
+            self.engine.activate(
+                deployment["engine_namespace"],
+                deployment["engine_definition_id"],
+            )
+            try:
+                response = self.engine.execute(
+                    deployment["engine_namespace"],
+                    deployment["engine_definition_id"],
+                    inputs=engine_inputs,
+                )
+            finally:
+                self.engine.deactivate(
+                    deployment["engine_namespace"],
+                    deployment["engine_definition_id"],
+                )
+            execution_id = (
+                response.get("id") if isinstance(response, Mapping) else None
+            )
+            if not execution_id:
+                raise RuntimeError("canary execution id is missing")
+            deadline = self.monotonic() + self.canary_timeout_seconds
+            execution = self.engine.get_execution(str(execution_id))
+            while True:
+                state = (
+                    execution.get("state")
+                    if isinstance(execution, Mapping)
+                    else None
+                )
+                if isinstance(state, Mapping):
+                    state = state.get("current")
+                normalized_state = str(state or "").upper()
+                if normalized_state in {
+                    "SUCCESS",
+                    "SUCCEEDED",
+                    "FAILED",
+                    "KILLED",
+                    "CANCELLED",
+                    "WARNING",
+                }:
+                    break
+                if self.monotonic() >= deadline:
+                    normalized_state = "UNKNOWN"
+                    break
+                self.sleeper(self.poll_interval_seconds)
+                execution = self.engine.get_execution(str(execution_id))
+        except Exception as exc:
+            self._engine_failure(operation, exc)
+        passed = normalized_state in {"SUCCESS", "SUCCEEDED"}
+        evidence_status = (
+            "passed"
+            if passed
+            else (
+                "failed"
+                if normalized_state
+                in {"FAILED", "KILLED", "CANCELLED", "WARNING"}
+                else "unknown"
+            )
+        )
+        now = self.clock().astimezone(UTC)
+        evidence = {
+            "id": new_governance_uid(),
+            "deployment_id": deployment["id"],
+            "execution_id": str(execution_id),
+            "status": evidence_status,
+            "package_hash": deployment["package_hash"],
+            "binding_hash": deployment["binding_hash"],
+            "schema_snapshot_hash": deployment["schema_snapshot_hash"],
+            "physical_plan_hashes": deployment["physical_plan_hashes"],
+            "workflow_spec_hash": deployment["workflow_spec_hash"],
+            "schedule_hash": deployment["schedule_hash"],
+            "engine_definition_hash": deployment["engine_definition_hash"],
+            "verified_by": actor,
+            "created_at": now.isoformat(),
+            "expires_at": (
+                now + timedelta(seconds=self.canary_ttl_seconds)
+            ).isoformat(),
+            "engine_response": _safe_receipt(execution),
+        }
+        try:
+            result = self.repository.record_canary(
+                deployment["id"],
+                deployment["lock_version"],
+                evidence,
+                operation["id"],
+            )
+        except Exception:
+            self._local_operation_failure(
+                operation, "canary_finalize_failed"
+            )
+            raise
+        self._commit()
+        return result
+
+    def _validate_evidence(
+        self, deployment: Mapping[str, Any], evidence_id: Any
+    ) -> dict[str, Any]:
+        if not evidence_id:
+            raise ValueError("passed canary evidence is required")
+        evidence = self.repository.get_canary_evidence(
+            _uid(evidence_id, "evidence_id")
+        )
+        if evidence.get("status") != "passed" or not evidence.get("verified_by"):
+            raise ValueError("passed canary evidence is required")
+        expires_at = _timestamp(evidence.get("expires_at"), "canary expiry")
+        if expires_at <= self.clock().astimezone(UTC):
+            raise ValueError("passed canary evidence has expired")
+        expected = {
+            "deployment_id": deployment["id"],
+            "package_hash": deployment["package_hash"],
+            "binding_hash": deployment["binding_hash"],
+            "schema_snapshot_hash": deployment["schema_snapshot_hash"],
+            "physical_plan_hashes": deployment["physical_plan_hashes"],
+            "workflow_spec_hash": deployment["workflow_spec_hash"],
+            "schedule_hash": deployment["schedule_hash"],
+            "engine_definition_hash": deployment["engine_definition_hash"],
+        }
+        if any(evidence.get(key) != value for key, value in expected.items()):
+            raise ValueError("canary evidence attestation has drifted")
+        return evidence
+
+    def activate(
+        self,
+        deployment_id: str,
+        evidence_id: str | None,
+        actor_uid: str,
+        *,
+        idempotency_key: str,
+        reason: str = "mass production activation",
+        correlation_id: str | None = None,
+    ) -> dict[str, Any]:
+        _text(reason, "reason", 1000)
+        deployment, actor, operation, replay = self._claim(
+            deployment_id,
+            "activate",
+            actor_uid,
+            idempotency_key,
+            correlation_id,
+            reason,
+            {"evidence_id": evidence_id},
+        )
+        if replay is not None:
+            return replay
+        try:
+            if deployment["status"] != "canary":
+                raise ValueError("passed canary deployment is required")
+            evidence = self._validate_evidence(deployment, evidence_id)
+        except Exception:
+            self._local_operation_failure(
+                operation, "activation_preflight_failed"
+            )
+            raise
+        try:
+            self.engine.activate(
+                deployment["engine_namespace"],
+                deployment["engine_definition_id"],
+            )
+        except Exception as exc:
+            self._engine_failure(operation, exc)
+        try:
+            result = self.repository.activate_atomic(
+                deployment["id"],
+                deployment["lock_version"],
+                evidence["id"],
+                actor,
+                operation["id"],
+            )
+            self._commit()
+            return result
+        except Exception:
+            try:
+                self.engine.deactivate(
+                    deployment["engine_namespace"],
+                    deployment["engine_definition_id"],
+                )
+            finally:
+                self.repository.fail_operation(
+                    operation["id"], "activation_commit_failure"
+                )
+                self._commit()
+            raise
+
+    def rollback(
+        self,
+        deployment_id: str,
+        actor_uid: str,
+        *,
+        idempotency_key: str,
+        reason: str = "restore previous production line",
+        correlation_id: str | None = None,
+    ) -> dict[str, Any]:
+        _text(reason, "reason", 1000)
+        deployment, actor, operation, replay = self._claim(
+            deployment_id,
+            "rollback",
+            actor_uid,
+            idempotency_key,
+            correlation_id,
+            reason,
+            {
+                "candidate_deployment_id": deployment_id,
+            },
+        )
+        if replay is not None:
+            return replay
+        try:
+            if deployment["status"] not in {"active", "failed"}:
+                raise ValueError("active deployment is required for rollback")
+            previous_id = deployment.get("previous_active_deployment_id")
+            if not previous_id:
+                raise ValueError("previous deployment is not available")
+            previous = self.repository.get_deployment(previous_id)
+            if (
+                previous.get("status") != "superseded"
+                or previous.get("dataflow_uid")
+                != deployment.get("dataflow_uid")
+                or previous.get("environment")
+                != deployment.get("environment")
+            ):
+                raise ValueError(
+                    "previous deployment snapshot is not restorable"
+                )
+        except Exception:
+            self._local_operation_failure(
+                operation, "rollback_preflight_failed"
+            )
+            raise
+        try:
+            # Fail-safe ordering: stop the candidate before restoring the exact
+            # prior engine definition and immutable package.
+            self.engine.deactivate(
+                deployment["engine_namespace"],
+                deployment["engine_definition_id"],
+            )
+            self.engine.activate(
+                previous["engine_namespace"],
+                previous["engine_definition_id"],
+            )
+        except Exception as exc:
+            try:
+                self.engine.activate(
+                    deployment["engine_namespace"],
+                    deployment["engine_definition_id"],
+                )
+            finally:
+                self._engine_failure(operation, exc)
+        try:
+            result = self.repository.rollback_atomic(
+                deployment["id"],
+                deployment["lock_version"],
+                actor,
+                operation["id"],
+            )
+            self._commit()
+            return result
+        except Exception:
+            # External state is reconciled back to the pre-operation state.
+            self.engine.deactivate(
+                previous["engine_namespace"], previous["engine_definition_id"]
+            )
+            self.engine.activate(
+                deployment["engine_namespace"],
+                deployment["engine_definition_id"],
+            )
+            self.repository.fail_operation(
+                operation["id"], "rollback_commit_failure"
+            )
+            self._commit()
+            raise
+
+    @staticmethod
+    def _flow_labels(flow: Mapping[str, Any]) -> dict[str, str]:
+        labels = flow.get("labels")
+        if isinstance(labels, Mapping):
+            return {str(key): str(value) for key, value in labels.items()}
+        if isinstance(labels, list):
+            return {
+                str(item.get("key")): str(item.get("value"))
+                for item in labels
+                if isinstance(item, Mapping)
+                and item.get("key") is not None
+                and item.get("value") is not None
+            }
+        return {}
+
+    def reconcile(
+        self,
+        deployment_id: str,
+        action: str,
+        actor_uid: str,
+        *,
+        idempotency_key: str,
+    ) -> dict[str, Any]:
+        """Resolve a response-loss outcome without replaying the side effect."""
+
+        deployment = self.repository.get_deployment(
+            _uid(deployment_id, "deployment_id")
+        )
+        actor = _uid(actor_uid, "actor_uid")
+        operation = self.repository.get_operation(
+            deployment["id"], action, self._operation_key(idempotency_key)
+        )
+        if operation.get("status") == "completed":
+            return operation["result"]
+        if operation.get("status") != "unknown":
+            raise ValueError("only an unknown operation may be reconciled")
+        if operation.get("actor_uid") != actor:
+            raise ValueError("operation is bound to another actor")
+        if action == "deploy_disabled":
+            compiled = self.compiler(
+                deployment["workflow_spec"],
+                deployment["schedule_snapshot"],
+                deployment["environment"],
+                int(deployment.get("version_no") or 1),
+            )
+            flow = self.engine.get_flow(compiled.namespace, compiled.flow_id)
+            labels = self._flow_labels(flow)
+            expected_labels = {
+                "dataflow_uid": deployment["dataflow_uid"],
+                "environment": deployment["environment"],
+                "workflow_version": str(
+                    int(deployment.get("version_no") or 1)
+                ),
+            }
+            if (
+                not isinstance(flow, Mapping)
+                or flow.get("disabled") is not True
+                or any(
+                    labels.get(key) != value
+                    for key, value in expected_labels.items()
+                )
+            ):
+                raise ValueError(
+                    "engine flow does not attest the unknown deployment"
+                )
+            self.repository.reclaim_unknown_operation(operation["id"])
+            self._commit()
+            result = self.repository.mark_disabled(
+                deployment["id"],
+                deployment["lock_version"],
+                {
+                    "namespace": compiled.namespace,
+                    "definition_id": compiled.flow_id,
+                    "revision": flow.get("revision"),
+                    "definition_hash": compiled.definition_hash,
+                    "receipt": _safe_receipt(flow),
+                },
+                operation["id"],
+            )
+            self._commit()
+            return result
+        if action == "activate":
+            evidence_id = operation.get("request", {}).get("evidence_id")
+            evidence = self._validate_evidence(deployment, evidence_id)
+            flow = self.engine.get_flow(
+                deployment["engine_namespace"],
+                deployment["engine_definition_id"],
+            )
+            if not isinstance(flow, Mapping) or flow.get("disabled") is not False:
+                raise ValueError("engine activation is not observable")
+            self.repository.reclaim_unknown_operation(operation["id"])
+            self._commit()
+            result = self.repository.activate_atomic(
+                deployment["id"],
+                deployment["lock_version"],
+                evidence["id"],
+                actor,
+                operation["id"],
+            )
+            self._commit()
+            return result
+        if action == "rollback":
+            if (
+                deployment.get("status") not in {"active", "failed"}
+                or not deployment.get("previous_active_deployment_id")
+            ):
+                raise ValueError("rollback deployment state is not recoverable")
+            previous = self.repository.get_deployment(
+                deployment["previous_active_deployment_id"]
+            )
+            if (
+                previous.get("status") != "superseded"
+                or previous.get("dataflow_uid")
+                != deployment.get("dataflow_uid")
+                or previous.get("environment")
+                != deployment.get("environment")
+            ):
+                raise ValueError("previous deployment is not restorable")
+            candidate_flow = self.engine.get_flow(
+                deployment["engine_namespace"],
+                deployment["engine_definition_id"],
+            )
+            previous_flow = self.engine.get_flow(
+                previous["engine_namespace"],
+                previous["engine_definition_id"],
+            )
+            if candidate_flow.get("disabled") is not True:
+                self.engine.deactivate(
+                    deployment["engine_namespace"],
+                    deployment["engine_definition_id"],
+                )
+            if previous_flow.get("disabled") is not False:
+                self.engine.activate(
+                    previous["engine_namespace"],
+                    previous["engine_definition_id"],
+                )
+            candidate_flow = self.engine.get_flow(
+                deployment["engine_namespace"],
+                deployment["engine_definition_id"],
+            )
+            previous_flow = self.engine.get_flow(
+                previous["engine_namespace"],
+                previous["engine_definition_id"],
+            )
+            if (
+                candidate_flow.get("disabled") is not True
+                or previous_flow.get("disabled") is not False
+            ):
+                raise ValueError("engine rollback state is not observable")
+            self.repository.reclaim_unknown_operation(operation["id"])
+            self._commit()
+            result = self.repository.rollback_atomic(
+                deployment["id"],
+                deployment["lock_version"],
+                actor,
+                operation["id"],
+            )
+            self._commit()
+            return result
+        raise ValueError(
+            "unknown operation requires execution-level manual reconciliation"
+        )
+
+    def list(self, *, environment: str | None = None):
+        if environment is not None and environment not in ENVIRONMENTS:
+            raise ValueError("deployment environment is invalid")
+        return self.repository.list_deployments(environment=environment)
+
+    def candidates(self, *, environment: str):
+        if environment not in ENVIRONMENTS:
+            raise ValueError("deployment environment is invalid")
+        return self.repository.list_deployable_releases(
+            environment=environment
+        )

+ 1088 - 0
app/core/data_rules/repository.py

@@ -4548,3 +4548,1091 @@ class DataRuleRepository:
                 "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.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, "
+                    "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]
+        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
+            ],
+            "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"),
+                },
+            },
+        }
+
+    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",
+        }:
+            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),
+        }
+        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) 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) "
+                    "ON CONFLICT (deployment_id, action, idempotency_key) "
+                    "DO NOTHING RETURNING id::text AS id, deployment_id::text, "
+                    "action, status, actor_uid::text, correlation_id::text, "
+                    "reason, request, request_hash, result, result_hash, error_code"
+                ),
+                params,
+            )
+            .mappings()
+            .one_or_none()
+        )
+        claimed = row is not None
+        if row is None:
+            row = (
+                self.session.execute(
+                    text(
+                        "SELECT id::text AS id, deployment_id::text, action, "
+                        "status, actor_uid::text, correlation_id::text, reason, "
+                        "request, request_hash, result, result_hash, error_code "
+                        "FROM public.dataflow_deployment_operations "
+                        "WHERE deployment_id = CAST(:deployment_id AS uuid) "
+                        "AND action = :action AND idempotency_key = :key"
+                    ),
+                    params,
+                )
+                .mappings()
+                .one()
+            )
+        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")
+        if (
+            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 "
+                    "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 reclaim_unknown_operation(self, operation_id: str) -> None:
+        updated = self.session.execute(
+            text(
+                "UPDATE public.dataflow_deployment_operations "
+                "SET status = 'claimed', error_code = NULL, "
+                "updated_at = CURRENT_TIMESTAMP "
+                "WHERE id = CAST(:id AS uuid) AND status = 'unknown'"
+            ),
+            {"id": _uid(operation_id, "operation_id")},
+        )
+        if getattr(updated, "rowcount", 0) != 1:
+            raise ValueError("unknown operation changed concurrently")
+
+    def fail_operation(self, operation_id: str, error_code: str) -> None:
+        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'"
+            ),
+            {
+                "id": _uid(operation_id, "operation_id"),
+                "error_code": _text(error_code, "error_code", 100),
+            },
+        )
+
+    def mark_operation_unknown(
+        self, operation_id: str, error_code: str
+    ) -> None:
+        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'"
+            ),
+            {
+                "id": _uid(operation_id, "operation_id"),
+                "error_code": _text(error_code, "error_code", 100),
+            },
+        )
+
+    def _complete_deployment_operation(
+        self, operation_id: str, result: dict[str, Any]
+    ) -> None:
+        operation = _uid(operation_id, "operation_id")
+        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'"
+            ),
+            {
+                "id": operation,
+                "result": _json(normalized),
+                "result_hash": _canonical_hash(normalized),
+            },
+        )
+        if getattr(updated, "rowcount", 0) != 1:
+            raise ValueError("deployment operation state changed concurrently")
+
+    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_id: str,
+    ) -> dict[str, Any]:
+        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, "
+                "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"
+                ),
+                "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_id, result)
+        return result
+
+    def record_canary(
+        self,
+        deployment_id: str,
+        expected_version: int,
+        evidence: dict[str, Any],
+        operation_id: str,
+    ) -> dict[str, Any]:
+        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, "
+                "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, 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_id, 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, "
+                    "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_id: str,
+    ) -> dict[str, Any]:
+        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_id, result)
+        return result
+
+    def rollback_atomic(
+        self,
+        deployment_id: str,
+        expected_version: int,
+        actor_uid: str,
+        operation_id: str,
+    ) -> dict[str, Any]:
+        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_id, 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

+ 10 - 4
app/core/orchestration/engines/kestra.py

@@ -2,7 +2,8 @@ from __future__ import annotations
 
 import json
 import re
-from typing import Any, Mapping, Optional
+from collections.abc import Mapping
+from typing import Any
 
 import requests
 
@@ -20,7 +21,7 @@ class KestraAdapter(WorkflowEngine):
         password: str,
         tenant_id: str = "main",
         timeout: int = 30,
-        session: Optional[Any] = None,
+        session: Any | None = None,
     ):
         normalized_url = (base_url or "").strip().rstrip("/")
         if not normalized_url:
@@ -116,7 +117,7 @@ class KestraAdapter(WorkflowEngine):
         self,
         namespace: str,
         definition_id: str,
-        inputs: Optional[Mapping[str, Any]] = None,
+        inputs: Mapping[str, Any] | None = None,
         **options: Any,
     ) -> Mapping[str, Any]:
         files = {
@@ -144,7 +145,7 @@ class KestraAdapter(WorkflowEngine):
         definition_id: str,
         trigger_id: str,
         start: str,
-        end: Optional[str],
+        end: str | None,
     ) -> Mapping[str, Any]:
         return self._request(
             "PUT",
@@ -160,5 +161,10 @@ class KestraAdapter(WorkflowEngine):
     def get_execution(self, execution_id: str) -> Mapping[str, Any]:
         return self._request("GET", f"executions/{execution_id}")
 
+    def get_flow(
+        self, namespace: str, definition_id: str
+    ) -> Mapping[str, Any]:
+        return self._request("GET", f"flows/{namespace}/{definition_id}")
+
     def get_logs(self, execution_id: str) -> Any:
         return self._request("GET", f"logs/{execution_id}")

+ 33 - 0
app/core/system/permissions.py

@@ -19,6 +19,11 @@ RULES_READ = "rules:read"
 RULES_EDIT = "rules:edit"
 RULES_PUBLISH = "rules:publish"
 DATAFLOW_RELEASE = "dataflows:release"
+DATAFLOW_DEPLOY = "dataflows:deploy"
+DATAFLOW_CANARY = "dataflows:canary"
+DATAFLOW_ACTIVATE = "dataflows:activate"
+DATAFLOW_ROLLBACK = "dataflows:rollback"
+RULES_EXECUTE = "rules:execute"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset({READ_GOVERNANCE, RULES_READ}),
@@ -30,6 +35,7 @@ ROLE_PERMISSIONS = {
             OPERATE_ORDERS,
             RULES_READ,
             RULES_EDIT,
+            RULES_EXECUTE,
         }
     ),
     "admin": frozenset(
@@ -46,6 +52,11 @@ ROLE_PERMISSIONS = {
             RULES_EDIT,
             RULES_PUBLISH,
             DATAFLOW_RELEASE,
+            DATAFLOW_DEPLOY,
+            DATAFLOW_CANARY,
+            DATAFLOW_ACTIVATE,
+            DATAFLOW_ROLLBACK,
+            RULES_EXECUTE,
         }
     ),
 }
@@ -61,6 +72,26 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
     if path in {"/api/knowledge/search", "/api/knowledge/ask"}:
         return (READ_GOVERNANCE,)
     if path.startswith("/api/rules"):
+        if path.startswith("/api/rules/deployments"):
+            if method == "GET":
+                return (RULES_READ,)
+            if path.endswith("/deploy-disabled"):
+                return (DATAFLOW_DEPLOY,)
+            if path.endswith("/canary"):
+                return (DATAFLOW_CANARY, RULES_EXECUTE)
+            if path.endswith("/activate"):
+                return (DATAFLOW_ACTIVATE,)
+            if path.endswith("/rollback"):
+                return (DATAFLOW_ROLLBACK,)
+            if path.endswith("/reconcile-deploy"):
+                return (DATAFLOW_DEPLOY,)
+            if path.endswith("/reconcile-activate"):
+                return (DATAFLOW_ACTIVATE,)
+            if path.endswith("/reconcile-rollback"):
+                return (DATAFLOW_ROLLBACK,)
+            if method == "POST":
+                return (DATAFLOW_DEPLOY,)
+            return (DATAFLOW_DEPLOY,)
         if method == "GET":
             return (RULES_READ,)
         if path.endswith("/publish"):
@@ -70,6 +101,8 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
             and path.endswith("/release")
         ):
             return (DATAFLOW_RELEASE,)
+        if path.endswith("/test") or path.endswith("/execute"):
+            return (RULES_EXECUTE,)
         if method in {"POST", "PUT", "PATCH", "DELETE"}:
             return (RULES_EDIT,)
         return (RULES_PUBLISH,)

+ 3 - 0
deploy/docker/.env.example

@@ -24,6 +24,9 @@ DATASOURCE_CERT_DIR=/etc/dataops-platform/datasource-certs
 RUNNER_TASK_TOKEN_SECRET=replace-with-at-least-32-random-bytes
 RUNNER_HTTP_ALLOWED_HOSTS=
 
+# Open only after disabled-deploy/canary/rollback/restart acceptance succeeds.
+DATA_FACTORY_ACTIVATION_ENABLED=false
+
 # DataOps MCP processes fail closed when any identity scope is empty.
 # Generate a fresh UUID correlation ID for each agent session.
 DATAOPS_MCP_SUBJECT=

+ 13 - 0
deploy/docker/README.md

@@ -55,6 +55,19 @@ docker compose -f deploy/docker/docker-compose.yml ps
 
 Kestra 固定使用开源版 `v1.3.20`,只连接独立的 `kestra` 元数据库,不持有业务
 数据源连接信息;业务数据访问仍由 DataOps 数据源资源池统一治理。
+Compose 会通过幂等引导任务创建本地专用 `kestra_runtime` 角色,并把既有 Kestra
+业务表迁移给该角色。该角色和口令只适用于本地验收栈,其他环境必须使用独立密钥
+注入。Kestra 使用有界连接池、连接校验、TCP keepalive、健康门禁和自动重启策略。
+
+数据工厂激活默认保持关闭。完成目标环境的禁用部署、试生产、回滚和恢复验收后,
+由环境负责人显式设置 `DATA_FACTORY_ACTIVATION_ENABLED=true` 再重启后端。部署和
+激活响应丢失会保留操作号,操作台通过观察 Kestra 确定状态后再完成对账。
+
+可运行 120 秒本地队列浸泡检查;时长可通过 `KESTRA_SOAK_SECONDS` 有界调整:
+
+```bash
+scripts/validation/kestra_queue_soak.sh
+```
 
 Kestra MCP 使用官方 Python MCP 镜像和 stdio 传输,不提供 HTTP 端口,也不作为
 常驻服务启动。AI/MCP 客户端可按需执行:

+ 43 - 6
deploy/docker/docker-compose.yml

@@ -21,6 +21,31 @@ services:
     networks:
       - dataops-test-net
 
+  kestra-db-init:
+    image: postgres:16-alpine
+    environment:
+      PGPASSWORD: dataops-test-password
+    volumes:
+      - ./postgres/init/010-kestra-runtime.sql:/opt/dataops/010-kestra-runtime.sql:ro
+    command:
+      - psql
+      - -v
+      - ON_ERROR_STOP=1
+      - -h
+      - postgres
+      - -U
+      - dataops
+      - -d
+      - postgres
+      - -f
+      - /opt/dataops/010-kestra-runtime.sql
+    depends_on:
+      postgres:
+        condition: service_healthy
+    restart: "no"
+    networks:
+      - dataops-test-net
+
   source-postgres:
     image: postgres:16-alpine
     environment:
@@ -164,8 +189,8 @@ services:
     environment:
       KESTRA_DB_HOST: postgres
       KESTRA_DB_NAME: kestra
-      KESTRA_DB_USERNAME: dataops
-      KESTRA_DB_PASSWORD: dataops-test-password
+      KESTRA_DB_USERNAME: kestra_runtime
+      KESTRA_DB_PASSWORD: kestra-local-test-password
       KESTRA_USERNAME: ${KESTRA_USERNAME:-admin@dataops.local}
       KESTRA_PASSWORD: ${KESTRA_PASSWORD:-DataOpsKestra1!}
     ports:
@@ -174,14 +199,15 @@ services:
       - dataops-test-kestra:/app/storage
       - ./kestra/application.yml:/etc/kestra/application.yml:ro
     depends_on:
-      postgres:
-        condition: service_healthy
+      kestra-db-init:
+        condition: service_completed_successfully
     healthcheck:
       test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8081/health"]
       interval: 10s
       timeout: 5s
-      retries: 30
-      start_period: 30s
+      retries: 60
+      start_period: 60s
+    restart: unless-stopped
     networks:
       - dataops-test-net
 
@@ -365,6 +391,13 @@ services:
       DATAFLOW_SCHEMA: dags
       N8N_API_URL: http://n8n:5678
       N8N_API_KEY: ${N8N_API_KEY:-}
+      KESTRA_BASE_URL: http://kestra:8080/api/v1
+      KESTRA_TENANT_ID: main
+      KESTRA_USERNAME: ${KESTRA_USERNAME:-admin@dataops.local}
+      KESTRA_PASSWORD: ${KESTRA_PASSWORD:-DataOpsKestra1!}
+      RUNNER_TASK_TOKEN_SECRET: ${RUNNER_TASK_TOKEN_SECRET:-dataops-local-runner-task-token-secret-change-me}
+      RUNNER_TASK_TOKEN_TTL_SECONDS: 60
+      DATA_FACTORY_ACTIVATION_ENABLED: ${DATA_FACTORY_ACTIVATION_ENABLED:-false}
       API_BASE_URL: http://backend:5500/api
       DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
       LLM_BASE_URL: https://api.deepseek.com
@@ -396,6 +429,10 @@ services:
         condition: service_healthy
       minio-init:
         condition: service_completed_successfully
+      kestra:
+        condition: service_healthy
+      runner:
+        condition: service_healthy
     healthcheck:
       test:
         - CMD

+ 11 - 2
deploy/docker/kestra/application.yml

@@ -1,9 +1,18 @@
 datasources:
   postgres:
-    url: jdbc:postgresql://${KESTRA_DB_HOST}:5432/${KESTRA_DB_NAME}
-    driverClassName: org.postgresql.Driver
+    url: jdbc:postgresql://${KESTRA_DB_HOST}:5432/${KESTRA_DB_NAME}?tcpKeepAlive=true
+    driver-class-name: org.postgresql.Driver
     username: ${KESTRA_DB_USERNAME}
     password: ${KESTRA_DB_PASSWORD}
+    pool-name: dataops-kestra-pool
+    maximum-pool-size: 20
+    minimum-idle: 4
+    connection-timeout: 30000
+    validation-timeout: 5000
+    idle-timeout: 300000
+    max-lifetime: 900000
+    initialization-fail-timeout: 30000
+    connection-test-query: SELECT 1
 
 kestra:
   server:

+ 49 - 0
deploy/docker/postgres/init/010-kestra-runtime.sql

@@ -0,0 +1,49 @@
+-- Keep the orchestration runtime isolated from the DataOps application role.
+-- This file is safe both during first-volume initialization and as a compose
+-- bootstrap job for an existing local acceptance volume.
+DO $$
+BEGIN
+    CREATE ROLE kestra_runtime LOGIN PASSWORD 'kestra-local-test-password';
+EXCEPTION
+    WHEN duplicate_object THEN
+        ALTER ROLE kestra_runtime LOGIN PASSWORD 'kestra-local-test-password';
+END $$;
+
+SELECT 'CREATE DATABASE kestra OWNER kestra_runtime'
+WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'kestra')\gexec
+
+ALTER DATABASE kestra OWNER TO kestra_runtime;
+
+\connect kestra
+DO $$
+DECLARE
+    item record;
+    object_kind text;
+BEGIN
+    FOR item IN
+        SELECT n.nspname AS schema_name, c.relname AS object_name, c.relkind
+        FROM pg_class c
+        JOIN pg_namespace n ON n.oid = c.relnamespace
+        JOIN pg_roles r ON r.oid = c.relowner
+        WHERE r.rolname = 'dataops'
+          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
+          AND n.nspname !~ '^pg_toast'
+          AND c.relkind IN ('r', 'p', 'v', 'm')
+    LOOP
+        object_kind := CASE item.relkind
+            WHEN 'S' THEN 'SEQUENCE'
+            WHEN 'v' THEN 'VIEW'
+            WHEN 'm' THEN 'MATERIALIZED VIEW'
+            ELSE 'TABLE'
+        END;
+        EXECUTE format(
+            'ALTER %s %I.%I OWNER TO kestra_runtime',
+            object_kind,
+            item.schema_name,
+            item.object_name
+        );
+    END LOOP;
+END $$;
+GRANT ALL ON SCHEMA public TO kestra_runtime;
+GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO kestra_runtime;
+GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO kestra_runtime;

+ 40 - 0
frontend/src/api/dataRules.js

@@ -78,3 +78,43 @@ export function publishStandardVersion (versionId) {
 export function releaseProductionLine (dataflowUid, payload) {
   return http.post(`/rules/production-lines/${dataflowUid}/release`, payload)
 }
+
+export function listDataFlowDeployments (params = {}) {
+  return http.get('/rules/deployments', params)
+}
+
+export function listDataFlowDeploymentCandidates (params = {}) {
+  return http.get('/rules/deployments/candidates', params)
+}
+
+export function createDataFlowDeployment (payload) {
+  return http.post('/rules/deployments', payload)
+}
+
+export function deployDataFlowDisabled (deploymentId, payload) {
+  return http.post(`/rules/deployments/${deploymentId}/deploy-disabled`, payload)
+}
+
+export function runDataFlowCanary (deploymentId, payload) {
+  return http.post(`/rules/deployments/${deploymentId}/canary`, payload)
+}
+
+export function activateDataFlowDeployment (deploymentId, payload) {
+  return http.post(`/rules/deployments/${deploymentId}/activate`, payload)
+}
+
+export function rollbackDataFlowDeployment (deploymentId, payload) {
+  return http.post(`/rules/deployments/${deploymentId}/rollback`, payload)
+}
+
+export function reconcileDataFlowDeploy (deploymentId, payload) {
+  return http.post(`/rules/deployments/${deploymentId}/reconcile-deploy`, payload)
+}
+
+export function reconcileDataFlowActivate (deploymentId, payload) {
+  return http.post(`/rules/deployments/${deploymentId}/reconcile-activate`, payload)
+}
+
+export function reconcileDataFlowRollback (deploymentId, payload) {
+  return http.post(`/rules/deployments/${deploymentId}/reconcile-rollback`, payload)
+}

+ 558 - 128
frontend/src/views/dataFactory/workflow/ProductionLineDeployment.vue

@@ -1,173 +1,603 @@
 <template>
-  <div class="deployment-page">
-    <div class="deployment-page__heading">
+  <section class="factory-console" aria-labelledby="factory-title">
+    <header class="factory-console__header">
       <div>
-        <h2>数据生产线投产</h2>
-        <p>
-          数据流程在这里以已发布版本投产。数据工厂只部署固定的标准、规则和执行计划,不重新生成代码。
-        </p>
+        <div class="eyebrow">DATA FACTORY · GOVERNED RELEASE</div>
+        <h2 id="factory-title">数据生产线投产</h2>
+        <p>把已发布的生产线送入目标工厂,先禁用部署,再试生产,最后切换到批量生产。</p>
       </div>
-      <v-btn color="primary" outlined :loading="loading" @click="loadCapabilities">
-        刷新能力状态
-      </v-btn>
-    </div>
-
-    <v-alert v-if="errorMessage" type="error" text>
-      {{ errorMessage }}
-    </v-alert>
-
-    <div class="deployment-gates">
-      <div v-for="gate in gates" :key="gate.key" class="deployment-gate">
-        <v-icon :color="gate.ready ? 'success' : 'warning'" class="mr-3">
-          {{ gate.ready ? 'mdi-check-circle' : 'mdi-clock-outline' }}
-        </v-icon>
-        <div>
-          <strong>{{ gate.title }}</strong>
-          <p>{{ gate.description }}</p>
-        </div>
-        <span :class="gate.ready ? 'ready' : 'pending'">
-          {{ gate.ready ? '可用' : '待实现' }}
-        </span>
+      <div class="header-actions">
+        <v-select
+          v-model="environment"
+          :items="environments"
+          item-text="label"
+          item-value="value"
+          dense
+          outlined
+          hide-details
+          aria-label="筛选工厂环境"
+          @change="loadDeployments"
+        />
+        <v-btn color="primary" :loading="loading" @click="refresh">
+          <v-icon left>mdi-refresh</v-icon>刷新
+        </v-btn>
+        <v-btn v-if="can('dataflows:deploy')" color="primary" outlined @click="openEntry">
+          <v-icon left>mdi-factory</v-icon>生产线入厂
+        </v-btn>
       </div>
-    </div>
+    </header>
 
-    <v-alert
-      v-if="capabilities.production_line_release"
-      class="mt-5 mb-0"
-      type="success"
-      text
-    >
-      发布与执行计划已经可用:数据流程可冻结标准、规则与计划哈希,形成不可变生产线包。
+    <v-alert v-if="errorMessage" type="error" text dismissible>
+      {{ errorMessage }}
+      <v-btn text small color="error" @click="refresh">重试</v-btn>
     </v-alert>
     <v-alert
-      v-if="!capabilities.data_factory_activation"
-      class="mt-3 mb-0"
+      v-else-if="readiness.reasons && readiness.reasons.length"
       type="warning"
       text
+      class="gate-alert"
     >
-      投产激活仍保持关闭。待运行适配器、Canary 证据与回滚闭环验收后,数据工厂才会开放激活操作。
+      <strong>批量生产门禁尚未完全开放</strong>
+      <span>{{ readinessText }}</span>
+    </v-alert>
+
+    <div class="factory-summary" aria-label="投产状态摘要">
+      <article v-for="metric in summary" :key="metric.label">
+        <span>{{ metric.label }}</span>
+        <strong>{{ metric.value }}</strong>
+        <small>{{ metric.note }}</small>
+      </article>
+    </div>
+
+    <v-skeleton-loader
+      v-if="loading && !deployments.length"
+      type="list-item-three-line, list-item-three-line"
+      class="mt-5"
+    />
+    <v-alert v-else-if="!deployments.length" type="info" text class="mt-5">
+      当前环境还没有待投产生产线。请先在“数据流程”发布生产线并完成物理计划测试。
     </v-alert>
-  </div>
+
+    <v-expansion-panels v-else v-model="openPanel" accordion class="deployment-list">
+      <v-expansion-panel v-for="item in deployments" :key="item.id">
+        <v-expansion-panel-header>
+          <div class="line-heading">
+            <div class="status-mark" :class="`status-mark--${item.status}`" />
+            <div>
+              <strong>{{ lineName(item) }}</strong>
+              <span>版本 {{ item.version_no || '—' }} · {{ environmentLabel(item.environment) }}</span>
+            </div>
+            <v-chip small label :color="statusMeta(item.status).color" text-color="white">
+              {{ statusMeta(item.status).label }}
+            </v-chip>
+          </div>
+        </v-expansion-panel-header>
+        <v-expansion-panel-content>
+          <div class="line-body">
+            <div class="hash-grid">
+              <div>
+                <span>生产线包 Hash</span>
+                <code :title="item.package_hash">{{ shortHash(item.package_hash) }}</code>
+              </div>
+              <div>
+                <span>数据绑定 Hash</span>
+                <code :title="item.binding_hash">{{ shortHash(item.binding_hash) }}</code>
+              </div>
+              <div>
+                <span>工作流 Hash</span>
+                <code :title="item.workflow_spec_hash">{{ shortHash(item.workflow_spec_hash) }}</code>
+              </div>
+              <div>
+                <span>引擎定义</span>
+                <code>{{ item.engine_namespace || '尚未部署' }} / {{ item.engine_definition_id || '—' }}</code>
+              </div>
+            </div>
+
+            <div class="station-grid">
+              <article>
+                <h4><v-icon small>mdi-database-import</v-icon>原料入口</h4>
+                <strong>{{ bindingName(item, 'input') }}</strong>
+                <span>{{ schemaName(item, 'input') }}</span>
+              </article>
+              <v-icon class="station-arrow">mdi-arrow-right</v-icon>
+              <article>
+                <h4><v-icon small>mdi-factory</v-icon>生产设备</h4>
+                <strong>{{ item.engine_revision ? `Kestra revision ${item.engine_revision}` : '等待禁用部署' }}</strong>
+                <span>{{ (item.physical_plan_hashes || []).length }} 个固定执行计划</span>
+              </article>
+              <v-icon class="station-arrow">mdi-arrow-right</v-icon>
+              <article>
+                <h4><v-icon small>mdi-package-variant-closed-check</v-icon>数据产品出口</h4>
+                <strong>{{ bindingName(item, 'output') }}</strong>
+                <span>{{ schemaName(item, 'output') }}</span>
+              </article>
+            </div>
+
+            <div class="evidence-row">
+              <div>
+                <span>试生产证据</span>
+                <strong>{{ item.canary_evidence_id ? shortHash(item.canary_evidence_id) : '尚未通过' }}</strong>
+              </div>
+              <div>
+                <span>上一稳定版本</span>
+                <strong>{{ item.previous_active_deployment_id ? shortHash(item.previous_active_deployment_id) : '无' }}</strong>
+              </div>
+              <div>
+                <span>当前锁版本</span>
+                <strong>{{ item.lock_version }}</strong>
+              </div>
+            </div>
+
+            <div class="action-bar">
+              <v-btn
+                v-if="can('dataflows:deploy')"
+                outlined
+                color="primary"
+                :disabled="!['draft', 'failed'].includes(item.status)"
+                :loading="isActing(item, 'deploy')"
+                @click="deployDisabled(item)"
+              >禁用部署</v-btn>
+              <v-btn
+                v-if="can('dataflows:canary') && can('rules:execute')"
+                outlined
+                color="primary"
+                :disabled="!['disabled', 'canary'].includes(item.status)"
+                @click="openCanary(item)"
+              >试生产</v-btn>
+              <v-btn
+                v-if="can('dataflows:activate')"
+                color="success"
+                :disabled="item.status !== 'canary' || !readiness.ready"
+                @click="confirmAction('activate', item)"
+              >转入批量生产</v-btn>
+              <v-btn
+                v-if="can('dataflows:rollback')"
+                outlined
+                color="error"
+                :disabled="!['active', 'failed'].includes(item.status) || !item.previous_active_deployment_id"
+                @click="confirmAction('rollback', item)"
+              >恢复上一条生产线</v-btn>
+              <v-btn
+                v-for="unknown in item.unknown_operations || []"
+                :key="`${unknown.action}:${unknown.idempotency_key}`"
+                outlined
+                color="warning"
+                @click="reconcileUnknown(item, unknown)"
+              >
+                <v-icon left>mdi-source-branch-sync</v-icon>
+                对账{{ unknownActionLabel(unknown.action) }}结果
+              </v-btn>
+            </div>
+
+            <div class="history">
+              <h4>状态履历</h4>
+              <div v-if="!(item.transition_history || []).length" class="history__empty">
+                履历将在每次投产动作后由服务器写入。
+              </div>
+              <div v-for="event in item.transition_history || []" :key="event.id" class="history__item">
+                <span>{{ event.created_at }}</span>
+                <strong>{{ event.from_status || '创建' }} → {{ event.to_status }}</strong>
+                <small>{{ event.reason }}</small>
+              </div>
+            </div>
+          </div>
+        </v-expansion-panel-content>
+      </v-expansion-panel>
+    </v-expansion-panels>
+
+    <v-dialog v-model="canaryDialog" max-width="480">
+      <v-card>
+        <v-card-title>启动试生产</v-card-title>
+        <v-card-text>
+          <p>试生产会短时启用当前生产线,完成后自动恢复禁用。参数不会改变生产线语义。</p>
+          <v-text-field v-model="bizDate" label="业务日期" type="date" outlined dense />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="canaryDialog = false">取消</v-btn>
+          <v-btn color="primary" :loading="dialogLoading" @click="runCanary">开始试生产</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="entryDialog" max-width="620">
+      <v-card>
+        <v-card-title>选择生产线进入{{ environmentLabel(environment) }}</v-card-title>
+        <v-card-text>
+          <p>这里只绑定已完成物理计划测试的发布版本。入厂不会重新生成或修改任何规则。</p>
+          <v-select
+            v-model="selectedCandidateId"
+            :items="candidateOptions"
+            item-text="text"
+            item-value="value"
+            label="已发布生产线"
+            outlined
+            :loading="candidateLoading"
+            no-data-text="没有满足入厂条件的发布版本"
+          />
+          <div v-if="selectedCandidate" class="candidate-preview">
+            <span>包 Hash:<code>{{ shortHash(selectedCandidate.package_hash) }}</code></span>
+            <span>输入:{{ selectedCandidate.binding_snapshot.input.object_ref }}</span>
+            <span>输出:{{ selectedCandidate.binding_snapshot.output.object_ref }}</span>
+            <span>{{ (selectedCandidate.physical_plan_hashes || []).length }} 个物理计划已固定</span>
+          </div>
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="entryDialog = false">取消</v-btn>
+          <v-btn color="primary" :disabled="!selectedCandidate" :loading="dialogLoading" @click="createDeployment">
+            确认入厂
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="confirmDialog" max-width="520">
+      <v-card>
+        <v-card-title>{{ pendingAction === 'activate' ? '确认转入批量生产' : '确认恢复上一条生产线' }}</v-card-title>
+        <v-card-text>
+          {{ pendingAction === 'activate'
+            ? '系统将以本次已通过的试生产证据激活该版本,并自动停用同环境的上一版本。'
+            : '系统会先停用当前生产线,再恢复上一条生产线的原包、原绑定和原引擎定义。' }}
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="confirmDialog = false">取消</v-btn>
+          <v-btn :color="pendingAction === 'activate' ? 'success' : 'error'" :loading="dialogLoading" @click="executeConfirmed">
+            确认执行
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+  </section>
 </template>
 
 <script>
-import { getRuleCapabilities } from '@/api/dataRules'
+import {
+  activateDataFlowDeployment,
+  createDataFlowDeployment,
+  deployDataFlowDisabled,
+  getRuleCapabilities,
+  listDataFlowDeploymentCandidates,
+  listDataFlowDeployments,
+  reconcileDataFlowActivate,
+  reconcileDataFlowDeploy,
+  reconcileDataFlowRollback,
+  rollbackDataFlowDeployment,
+  runDataFlowCanary
+} from '@/api/dataRules'
+
+const STATUS = {
+  draft: { label: '待入厂', color: 'blue-grey' },
+  disabled: { label: '已禁用部署', color: 'indigo' },
+  canary: { label: '试生产通过', color: 'teal' },
+  active: { label: '批量生产中', color: 'green' },
+  superseded: { label: '已被替代', color: 'grey' },
+  rolled_back: { label: '已回滚', color: 'deep-orange' },
+  failed: { label: '需要恢复', color: 'red' }
+}
 
 export default {
   name: 'ProductionLineDeployment',
   data () {
     return {
       loading: false,
-      capabilities: {},
-      errorMessage: ''
+      errorMessage: '',
+      environment: 'production',
+      environments: [
+        { label: '生产工厂', value: 'production' },
+        { label: '测试工厂', value: 'test' },
+        { label: '开发工厂', value: 'development' }
+      ],
+      deployments: [],
+      readiness: { ready: false, reasons: [] },
+      openPanel: 0,
+      actionState: {},
+      canaryDialog: false,
+      entryDialog: false,
+      confirmDialog: false,
+      dialogLoading: false,
+      selected: null,
+      pendingAction: '',
+      bizDate: new Date().toISOString().slice(0, 10),
+      candidates: [],
+      candidateLoading: false,
+      selectedCandidateId: ''
     }
   },
   computed: {
-    gates () {
+    permissions () {
+      return this.$store.getters.userInfo?.permissions || []
+    },
+    readinessText () {
+      const labels = {
+        kestra_base_url_missing: '调度服务地址未配置',
+        kestra_username_missing: '调度服务账号未配置',
+        kestra_password_missing: '调度服务认证未配置',
+        runner_task_token_secret_invalid: '执行器短期令牌未配置',
+        post_acceptance_activation_gate_closed: '验收后的激活开关仍关闭'
+      }
+      return (this.readiness.reasons || []).map(item => labels[item] || item).join(';')
+    },
+    summary () {
+      const count = status => this.deployments.filter(item => item.status === status).length
       return [
-        {
-          key: 'authoring',
-          title: 'AI 规则定义',
-          description: '自然语言规则生成封闭 RuleSpec,并保留人工确认。',
-          ready: Boolean(this.capabilities.natural_language_authoring)
-        },
-        {
-          key: 'release',
-          title: '发布版本',
-          description: '冻结数据标准、数据规则和数据流程版本。',
-          ready: Boolean(this.capabilities.production_line_release)
-        },
-        {
-          key: 'plan',
-          title: '执行计划',
-          description: '执行器按计划哈希加载,不接受流程内联脚本。',
-          ready: Boolean(this.capabilities.production_line_release)
-        },
-        {
-          key: 'activation',
-          title: '数据工厂激活',
-          description: '审批后将同一生产线包部署到目标环境。',
-          ready: Boolean(this.capabilities.data_factory_activation)
-        }
+        { label: '待试生产', value: count('disabled'), note: '已入厂但尚未取得证据' },
+        { label: '批量生产', value: count('active'), note: '当前环境正在运行' },
+        { label: '可回滚版本', value: this.deployments.filter(item => item.previous_active_deployment_id).length, note: '保留精确恢复点' }
       ]
+    },
+    candidateOptions () {
+      return this.candidates.map(item => ({
+        value: item.id,
+        text: `${item.package?.workflow_spec?.name || item.id} · v${item.version_no}`
+      }))
+    },
+    selectedCandidate () {
+      return this.candidates.find(item => item.id === this.selectedCandidateId) || null
     }
   },
   created () {
-    this.loadCapabilities()
+    this.refresh()
   },
   methods: {
-    async loadCapabilities () {
+    can (permission) {
+      return this.permissions.includes(permission)
+    },
+    statusMeta (status) {
+      return STATUS[status] || { label: status || '未知', color: 'grey' }
+    },
+    environmentLabel (value) {
+      return this.environments.find(item => item.value === value)?.label || value
+    },
+    shortHash (value) {
+      return value ? `${value.slice(0, 10)}…${value.slice(-6)}` : '—'
+    },
+    lineName (item) {
+      return item.package?.workflow_spec?.name || item.workflow_spec?.name || `生产线 ${this.shortHash(item.dataflow_uid)}`
+    },
+    bindingName (item, side) {
+      return item.binding_snapshot?.[side]?.object_ref || '未绑定'
+    },
+    schemaName (item, side) {
+      const schema = item.schema_snapshots?.[side]
+      return schema ? `${schema.schema_ref || 'Schema'} · ${this.shortHash(schema.schema_hash)}` : 'Schema 未固定'
+    },
+    isActing (item, action) {
+      return this.actionState[item.id] === action
+    },
+    unknownActionLabel (action) {
+      return { activate: '激活', rollback: '回滚', deploy_disabled: '部署' }[action] || '投产'
+    },
+    async refresh () {
       this.loading = true
       this.errorMessage = ''
       try {
-        const { data } = await getRuleCapabilities()
-        this.capabilities = data || {}
+        const [capabilityResult, deploymentResult] = await Promise.all([
+          getRuleCapabilities(),
+          listDataFlowDeployments({ environment: this.environment })
+        ])
+        this.readiness = capabilityResult.data?.data_factory_readiness || { ready: false, reasons: [] }
+        this.deployments = deploymentResult.data?.items || []
+        if (deploymentResult.data?.readiness) this.readiness = deploymentResult.data.readiness
       } catch (error) {
-        this.errorMessage = error || '无法获取生产线能力状态'
+        this.errorMessage = error?.message || error || '无法加载数据工厂投产状态'
       } finally {
         this.loading = false
       }
+    },
+    loadDeployments () {
+      this.refresh()
+    },
+    operationKey (action, item) {
+      const storageKey = `dataFactoryOperation:${action}:${item.id}`
+      let value = window.localStorage.getItem(storageKey)
+      if (!value) {
+        value = `${action}:${item.id}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`
+        window.localStorage.setItem(storageKey, value)
+      }
+      return value
+    },
+    clearOperationKey (action, item) {
+      window.localStorage.removeItem(`dataFactoryOperation:${action}:${item.id}`)
+    },
+    clearTerminalOperationKey (error, action, item) {
+      if (Number(error?.code) === 409) {
+        this.clearOperationKey(action, item)
+      }
+    },
+    async openEntry () {
+      this.entryDialog = true
+      this.candidateLoading = true
+      this.selectedCandidateId = ''
+      try {
+        const result = await listDataFlowDeploymentCandidates({ environment: this.environment })
+        this.candidates = result.data?.items || []
+        this.selectedCandidateId = this.candidates[0]?.id || ''
+      } catch (error) {
+        this.errorMessage = error?.message || error || '无法加载可入厂生产线'
+      } finally {
+        this.candidateLoading = false
+      }
+    },
+    async createDeployment () {
+      const candidate = this.selectedCandidate
+      if (!candidate) return
+      this.dialogLoading = true
+      const keyTarget = { id: candidate.preparatory_deployment_id || candidate.id }
+      try {
+        await createDataFlowDeployment({
+          dataflow_version_id: candidate.id,
+          environment: this.environment,
+          binding_snapshot: {
+            input: { binding_id: candidate.binding_snapshot.input.binding_id },
+            output: { binding_id: candidate.binding_snapshot.output.binding_id }
+          },
+          schedule_plan: {
+            schema_version: '1.0',
+            timezone: 'Asia/Shanghai',
+            triggers: [{ type: 'manual' }],
+            max_concurrency: 1,
+            timeout_seconds: 3600,
+            retry: { max_attempts: 1, delay_seconds: 30 },
+            conflict_policy: 'queue',
+            backfill: { max_days: 1, max_runs: 1 }
+          },
+          reason: 'operator admitted released production line',
+          idempotency_key: this.operationKey('create', keyTarget)
+        })
+        this.clearOperationKey('create', keyTarget)
+        this.entryDialog = false
+        await this.refresh()
+      } catch (error) {
+        this.clearTerminalOperationKey(error, 'create', keyTarget)
+        this.errorMessage = error?.message || error || '生产线入厂失败;已保留本次操作号供安全重试'
+      } finally {
+        this.dialogLoading = false
+      }
+    },
+    async deployDisabled (item) {
+      this.$set(this.actionState, item.id, 'deploy')
+      try {
+        await deployDataFlowDisabled(item.id, {
+          reason: 'operator disabled deployment',
+          idempotency_key: this.operationKey('deploy', item)
+        })
+        this.clearOperationKey('deploy', item)
+        await this.refresh()
+      } catch (error) {
+        this.clearTerminalOperationKey(error, 'deploy', item)
+        this.errorMessage = error?.message || error || '禁用部署失败'
+      } finally {
+        this.$delete(this.actionState, item.id)
+      }
+    },
+    openCanary (item) {
+      this.selected = item
+      this.canaryDialog = true
+    },
+    async runCanary () {
+      this.dialogLoading = true
+      try {
+        await runDataFlowCanary(this.selected.id, {
+          inputs: { biz_date: this.bizDate },
+          reason: 'operator trial production',
+          idempotency_key: this.operationKey('canary', this.selected)
+        })
+        this.clearOperationKey('canary', this.selected)
+        this.canaryDialog = false
+        await this.refresh()
+      } catch (error) {
+        this.clearTerminalOperationKey(error, 'canary', this.selected)
+        this.errorMessage = error?.message || error || '试生产失败'
+      } finally {
+        this.dialogLoading = false
+      }
+    },
+    confirmAction (action, item) {
+      this.pendingAction = action
+      this.selected = item
+      this.confirmDialog = true
+    },
+    async executeConfirmed () {
+      this.dialogLoading = true
+      try {
+        if (this.pendingAction === 'activate') {
+          await activateDataFlowDeployment(this.selected.id, {
+            evidence_id: this.selected.canary_evidence_id,
+            reason: 'operator mass production activation',
+            idempotency_key: this.operationKey('activate', this.selected)
+          })
+          this.clearOperationKey('activate', this.selected)
+        } else {
+          await rollbackDataFlowDeployment(this.selected.id, {
+            reason: 'operator restore previous production line',
+            idempotency_key: this.operationKey('rollback', this.selected)
+          })
+          this.clearOperationKey('rollback', this.selected)
+        }
+        this.confirmDialog = false
+        await this.refresh()
+      } catch (error) {
+        this.clearTerminalOperationKey(error, this.pendingAction, this.selected)
+        this.errorMessage = error?.message || error || '投产操作失败'
+      } finally {
+        this.dialogLoading = false
+      }
+    },
+    async reconcileUnknown (item, unknown) {
+      this.$set(this.actionState, item.id, 'reconcile')
+      try {
+        const payload = { idempotency_key: unknown.idempotency_key }
+        if (unknown.action === 'activate') {
+          await reconcileDataFlowActivate(item.id, payload)
+        } else if (unknown.action === 'rollback') {
+          await reconcileDataFlowRollback(item.id, payload)
+        } else {
+          await reconcileDataFlowDeploy(item.id, payload)
+        }
+        const actionKey = { activate: 'activate', rollback: 'rollback', deploy_disabled: 'deploy' }[unknown.action]
+        if (actionKey) this.clearOperationKey(actionKey, item)
+        await this.refresh()
+      } catch (error) {
+        this.errorMessage = error?.message || error || '未知投产结果仍无法确认,请保持当前操作号并联系平台管理员'
+      } finally {
+        this.$delete(this.actionState, item.id)
+      }
     }
   }
 }
 </script>
 
 <style lang="scss" scoped>
-.deployment-page {
-  padding: 28px 24px;
-  min-height: 440px;
-}
-
-.deployment-page__heading {
-  display: flex;
-  align-items: flex-start;
-  justify-content: space-between;
-  gap: 24px;
-  margin-bottom: 24px;
-}
-
-.deployment-page__heading h2 {
-  margin: 0 0 8px;
-  color: #17324d;
-  font-size: 22px;
-}
-
-.deployment-page__heading p,
-.deployment-gate p {
-  margin: 0;
-  color: #60758a;
-  line-height: 1.6;
-}
-
-.deployment-gates {
-  border-top: 1px solid #dbe4ee;
-}
-
-.deployment-gate {
-  display: grid;
-  grid-template-columns: auto 1fr auto;
-  align-items: center;
-  gap: 4px;
-  min-height: 82px;
-  border-bottom: 1px solid #dbe4ee;
-}
-
-.deployment-gate strong {
-  color: #233c55;
-}
-
-.deployment-gate span {
-  min-width: 62px;
-  text-align: right;
-  font-size: 13px;
-  font-weight: 600;
-}
-
-.ready {
-  color: #2e7d32;
-}
-
-.pending {
-  color: #ed6c02;
+.factory-console { padding: 28px 24px 40px; min-height: 560px; background: #f7f9fc; color: #20364d; }
+.factory-console__header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; }
+.factory-console__header h2 { margin: 3px 0 6px; font-size: 24px; letter-spacing: -.02em; }
+.factory-console__header p { margin: 0; color: #60758a; }
+.eyebrow { color: #376995; font-size: 11px; letter-spacing: .14em; font-weight: 700; }
+.header-actions { display: flex; gap: 10px; min-width: 330px; }
+.header-actions .v-select { max-width: 190px; background: #fff; }
+.gate-alert span { display: block; margin-top: 4px; }
+.factory-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 22px; }
+.factory-summary article { border: 1px solid #dce5ef; border-radius: 8px; background: #fff; padding: 16px 18px; }
+.factory-summary span, .factory-summary small { display: block; color: #6c7f92; }
+.factory-summary strong { display: block; margin: 5px 0 2px; font-size: 26px; color: #17324d; }
+.deployment-list { margin-top: 18px; }
+.line-heading { width: 100%; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; padding-right: 14px; }
+.line-heading strong, .line-heading span { display: block; }
+.line-heading span { margin-top: 3px; color: #718397; font-size: 12px; }
+.status-mark { width: 8px; height: 36px; border-radius: 4px; background: #90a4ae; }
+.status-mark--active { background: #2e7d32; }
+.status-mark--canary { background: #00897b; }
+.status-mark--failed { background: #d32f2f; }
+.line-body { border-top: 1px solid #e4eaf1; padding-top: 18px; }
+.hash-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 24px; }
+.hash-grid div { display: flex; justify-content: space-between; gap: 16px; border-bottom: 1px solid #edf1f5; padding: 8px 0; }
+.hash-grid span { color: #708397; font-size: 12px; }
+.hash-grid code { color: #244c70; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.station-grid { display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; gap: 12px; align-items: center; margin: 22px 0; }
+.station-grid article { min-height: 96px; border: 1px solid #dbe4ee; background: #f9fbfd; border-radius: 8px; padding: 14px; }
+.station-grid h4 { margin: 0 0 10px; color: #4e6a83; font-size: 12px; }
+.station-grid strong, .station-grid span { display: block; overflow-wrap: anywhere; }
+.station-grid span { margin-top: 5px; color: #718397; font-size: 11px; }
+.station-arrow { color: #8aa3ba; }
+.evidence-row { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid #dce5ef; border-radius: 8px; }
+.evidence-row div { padding: 12px 14px; border-right: 1px solid #dce5ef; }
+.evidence-row div:last-child { border-right: 0; }
+.evidence-row span, .evidence-row strong { display: block; }
+.evidence-row span { color: #718397; font-size: 11px; margin-bottom: 4px; }
+.action-bar { display: flex; flex-wrap: wrap; gap: 10px; margin: 18px 0; }
+.history { border-top: 1px solid #e3eaf1; padding-top: 15px; }
+.history h4 { margin: 0 0 10px; }
+.history__empty { color: #8494a5; font-size: 12px; }
+.history__item { display: grid; grid-template-columns: 160px 170px 1fr; gap: 10px; font-size: 12px; padding: 6px 0; }
+.candidate-preview { display: grid; gap: 7px; padding: 14px; border: 1px solid #dce5ef; border-radius: 8px; background: #f8fafc; color: #536b82; }
+@media (max-width: 900px) {
+  .factory-console__header { flex-direction: column; }
+  .header-actions { min-width: 0; width: 100%; }
+  .factory-summary, .hash-grid, .evidence-row { grid-template-columns: 1fr; }
+  .station-grid { grid-template-columns: 1fr; }
+  .station-arrow { transform: rotate(90deg); justify-self: center; }
+  .history__item { grid-template-columns: 1fr; }
 }
 </style>

+ 214 - 0
migrations/versions/20260724_240_data_factory_deployments.py

@@ -0,0 +1,214 @@
+"""Add recoverable immutable Data Factory deployment lifecycle."""
+
+from alembic import op
+
+
+revision = "20260724_240"
+down_revision = "20260724_230"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        ALTER TABLE public.dataflow_deployments
+            ADD COLUMN dataflow_uid UUID,
+            ADD COLUMN package JSONB,
+            ADD COLUMN package_hash CHAR(64),
+            ADD COLUMN standard_version_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
+            ADD COLUMN rule_version_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
+            ADD COLUMN binding_snapshot JSONB,
+            ADD COLUMN binding_hash CHAR(64),
+            ADD COLUMN schema_snapshots JSONB,
+            ADD COLUMN schema_snapshot_hash CHAR(64),
+            ADD COLUMN physical_plan_hashes JSONB NOT NULL DEFAULT '[]'::jsonb,
+            ADD COLUMN workflow_spec JSONB,
+            ADD COLUMN workflow_spec_hash CHAR(64),
+            ADD COLUMN schedule_snapshot JSONB,
+            ADD COLUMN schedule_hash CHAR(64),
+            ADD COLUMN engine_namespace VARCHAR(200),
+            ADD COLUMN engine_definition_id VARCHAR(200),
+            ADD COLUMN engine_revision VARCHAR(200),
+            ADD COLUMN engine_definition_hash CHAR(64),
+            ADD COLUMN deploy_receipt JSONB NOT NULL DEFAULT '{}'::jsonb,
+            ADD COLUMN canary_evidence_id UUID,
+            ADD COLUMN previous_active_deployment_id UUID
+                REFERENCES public.dataflow_deployments(id) ON DELETE RESTRICT,
+            ADD COLUMN created_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
+            ADD COLUMN create_reason VARCHAR(1000),
+            ADD COLUMN create_idempotency_key VARCHAR(200),
+            ADD COLUMN correlation_id UUID,
+            ADD COLUMN lock_version INTEGER NOT NULL DEFAULT 0,
+            ADD COLUMN failure_code VARCHAR(100),
+            ADD COLUMN superseded_at TIMESTAMPTZ,
+            ADD COLUMN rolled_back_at TIMESTAMPTZ;
+
+        UPDATE public.dataflow_deployments d
+        SET dataflow_uid = v.dataflow_uid
+        FROM public.dataflow_versions v
+        WHERE v.id = d.dataflow_version_id
+          AND d.dataflow_uid IS NULL;
+
+        ALTER TABLE public.dataflow_deployments
+            ADD CONSTRAINT ck_dataflow_deployment_lock_version
+                CHECK (lock_version >= 0),
+            ADD CONSTRAINT ck_dataflow_deployment_hash_shape
+                CHECK (
+                    (package_hash IS NULL OR package_hash ~ '^[0-9a-f]{64}$')
+                    AND (binding_hash IS NULL OR binding_hash ~ '^[0-9a-f]{64}$')
+                    AND (
+                        schema_snapshot_hash IS NULL
+                        OR schema_snapshot_hash ~ '^[0-9a-f]{64}$'
+                    )
+                    AND (
+                        workflow_spec_hash IS NULL
+                        OR workflow_spec_hash ~ '^[0-9a-f]{64}$'
+                    )
+                    AND (
+                        schedule_hash IS NULL
+                        OR schedule_hash ~ '^[0-9a-f]{64}$'
+                    )
+                    AND (
+                        engine_definition_hash IS NULL
+                        OR engine_definition_hash ~ '^[0-9a-f]{64}$'
+                    )
+                ),
+            ADD CONSTRAINT ck_dataflow_deployment_snapshot_complete
+                CHECK (
+                    package IS NULL
+                    OR (
+                        dataflow_uid IS NOT NULL
+                        AND package_hash IS NOT NULL
+                        AND binding_snapshot IS NOT NULL
+                        AND binding_hash IS NOT NULL
+                        AND schema_snapshots IS NOT NULL
+                        AND schema_snapshot_hash IS NOT NULL
+                        AND jsonb_array_length(physical_plan_hashes) > 0
+                        AND workflow_spec IS NOT NULL
+                        AND workflow_spec_hash IS NOT NULL
+                        AND schedule_snapshot IS NOT NULL
+                        AND schedule_hash IS NOT NULL
+                        AND created_by IS NOT NULL
+                        AND create_reason IS NOT NULL
+                        AND create_idempotency_key IS NOT NULL
+                        AND correlation_id IS NOT NULL
+                    )
+                );
+
+        ALTER TABLE public.dataflow_deployments
+            DROP CONSTRAINT IF EXISTS
+                dataflow_deployments_dataflow_version_id_environment_key;
+        DROP INDEX IF EXISTS public.uq_dataflow_deployment_active_environment;
+        CREATE UNIQUE INDEX uq_dataflow_deployment_active_environment
+            ON public.dataflow_deployments(dataflow_uid, environment)
+            WHERE status = 'active';
+        CREATE UNIQUE INDEX uq_dataflow_deployment_create_idempotency
+            ON public.dataflow_deployments(
+                created_by, environment, create_idempotency_key
+            )
+            WHERE create_idempotency_key IS NOT NULL;
+        CREATE INDEX idx_dataflow_deployment_factory_list
+            ON public.dataflow_deployments(
+                environment, updated_at DESC, dataflow_uid
+            )
+            WHERE package IS NOT NULL;
+
+        CREATE TABLE public.dataflow_canary_evidence (
+            id UUID PRIMARY KEY,
+            deployment_id UUID NOT NULL
+                REFERENCES public.dataflow_deployments(id) ON DELETE RESTRICT,
+            execution_id VARCHAR(200) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('started','passed','failed','unknown')
+            ),
+            package_hash CHAR(64) NOT NULL,
+            binding_hash CHAR(64) NOT NULL,
+            schema_snapshot_hash CHAR(64) NOT NULL,
+            physical_plan_hashes JSONB NOT NULL,
+            workflow_spec_hash CHAR(64) NOT NULL,
+            schedule_hash CHAR(64) NOT NULL,
+            engine_definition_hash CHAR(64) NOT NULL,
+            verified_by UUID NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
+            engine_response JSONB NOT NULL DEFAULT '{}'::jsonb,
+            expires_at TIMESTAMPTZ NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (deployment_id, execution_id)
+        );
+        CREATE INDEX idx_dataflow_canary_activation_gate
+            ON public.dataflow_canary_evidence(
+                deployment_id, status, expires_at DESC
+            );
+
+        ALTER TABLE public.dataflow_deployments
+            ADD CONSTRAINT fk_dataflow_deployment_canary_evidence
+            FOREIGN KEY (canary_evidence_id)
+            REFERENCES public.dataflow_canary_evidence(id)
+            ON DELETE RESTRICT;
+
+        CREATE TABLE public.dataflow_deployment_operations (
+            id UUID PRIMARY KEY,
+            deployment_id UUID NOT NULL
+                REFERENCES public.dataflow_deployments(id) ON DELETE RESTRICT,
+            action VARCHAR(30) NOT NULL CHECK (
+                action IN (
+                    'deploy_disabled','run_canary','activate','rollback'
+                )
+            ),
+            idempotency_key VARCHAR(200) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('claimed','completed','failed','unknown')
+            ),
+            actor_uid UUID NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
+            correlation_id UUID NOT NULL,
+            reason VARCHAR(1000) NOT NULL,
+            request JSONB NOT NULL DEFAULT '{}'::jsonb,
+            request_hash CHAR(64) NOT NULL,
+            result JSONB,
+            result_hash CHAR(64),
+            error_code VARCHAR(100),
+            started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            completed_at TIMESTAMPTZ,
+            failed_at TIMESTAMPTZ,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (deployment_id, action, idempotency_key),
+            CHECK (request_hash ~ '^[0-9a-f]{64}$'),
+            CHECK (
+                (status = 'completed' AND result IS NOT NULL
+                    AND result_hash ~ '^[0-9a-f]{64}$')
+                OR status <> 'completed'
+            )
+        );
+        CREATE INDEX idx_dataflow_deployment_operation_recovery
+            ON public.dataflow_deployment_operations(
+                status, updated_at, deployment_id
+            )
+            WHERE status IN ('claimed','failed','unknown');
+
+        CREATE TABLE public.dataflow_deployment_transitions (
+            id UUID PRIMARY KEY,
+            deployment_id UUID NOT NULL
+                REFERENCES public.dataflow_deployments(id) ON DELETE RESTRICT,
+            operation_id UUID
+                REFERENCES public.dataflow_deployment_operations(id)
+                ON DELETE RESTRICT,
+            from_status VARCHAR(30),
+            to_status VARCHAR(30) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
+            reason VARCHAR(1000) NOT NULL,
+            correlation_id UUID NOT NULL,
+            detail JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_dataflow_deployment_transition_history
+            ON public.dataflow_deployment_transitions(
+                deployment_id, created_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "Data Factory deployment evidence is forward-only and cannot downgrade"
+    )

+ 30 - 0
scripts/validation/kestra_queue_soak.sh

@@ -0,0 +1,30 @@
+#!/bin/sh
+set -eu
+
+compose_file=${COMPOSE_FILE:-deploy/docker/docker-compose.yml}
+duration=${KESTRA_SOAK_SECONDS:-120}
+interval=${KESTRA_SOAK_INTERVAL_SECONDS:-5}
+deadline=$(( $(date +%s) + duration ))
+kestra_container=$(docker compose -f "$compose_file" ps -q kestra)
+initial_restarts=$(docker inspect -f '{{.RestartCount}}' "$kestra_container")
+checks=0
+
+while [ "$(date +%s)" -lt "$deadline" ]; do
+  docker compose -f "$compose_file" exec -T kestra \
+    curl -fsS http://127.0.0.1:8081/health >/dev/null
+  current_restarts=$(docker inspect -f '{{.RestartCount}}' "$kestra_container")
+  if [ "$current_restarts" != "$initial_restarts" ]; then
+    echo "Kestra restarted during soak: ${initial_restarts} -> ${current_restarts}" >&2
+    exit 1
+  fi
+  checks=$((checks + 1))
+  sleep "$interval"
+done
+
+if docker compose -f "$compose_file" logs --since "${duration}s" kestra |
+  grep -Eiq 'broken pipe|connection is closed|connection reset|hikaripool.*failed|outofmemory|fatal'; then
+  echo "Kestra emitted a fatal database/queue signature during soak" >&2
+  exit 1
+fi
+
+echo "Kestra soak passed: ${checks} health checks, restart count ${initial_restarts}"

+ 681 - 0
tests/core/data_rules/test_deployment.py

@@ -0,0 +1,681 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from datetime import UTC, datetime, timedelta
+
+import pytest
+import requests
+
+from app.core.common.identifiers import new_governance_uid
+
+ACTOR = new_governance_uid()
+FLOW_UID = new_governance_uid()
+VERSION_ID = new_governance_uid()
+
+
+def _release():
+    package = {
+        "schema_version": "1.0",
+        "dataflow_uid": FLOW_UID,
+        "package_hash": "a" * 64,
+        "standard_version_ids": [new_governance_uid()],
+        "rule_version_ids": [new_governance_uid()],
+        "execution_plans": [
+            {
+                "component_binding_id": new_governance_uid(),
+                "rule_version_id": new_governance_uid(),
+                "plan_hash": "b" * 64,
+                "backend": "sql_pushdown",
+                "plan_kind": "semantic",
+                "status": "compiled",
+            }
+        ],
+        "workflow_spec": {
+            "schema_version": "1.0",
+            "dataflow_uid": FLOW_UID,
+            "name": "customer_clean",
+            "nodes": [],
+            "edges": [],
+            "parameters": {"biz_date": {"type": "string", "required": True}},
+            "labels": {},
+        },
+    }
+    return {
+        "id": VERSION_ID,
+        "version_no": 7,
+        "status": "released",
+        "package": package,
+        "package_hash": package["package_hash"],
+        "schema_snapshots": {
+            "input": {"id": new_governance_uid(), "schema_hash": "c" * 64},
+            "output": {"id": new_governance_uid(), "schema_hash": "d" * 64},
+        },
+        "physical_plan_hashes": ["e" * 64],
+    }
+
+
+def _binding():
+    return {
+        "input": {
+            "data_source_uid": new_governance_uid(),
+            "object_kind": "table",
+            "object_ref": "landing.customer",
+            "schema_snapshot_id": new_governance_uid(),
+            "schema_hash": "c" * 64,
+            "binding_hash": "f" * 64,
+            "access_mode": "read",
+            "dialect": "postgresql",
+        },
+        "output": {
+            "data_source_uid": new_governance_uid(),
+            "object_kind": "table",
+            "object_ref": "curated.customer",
+            "schema_snapshot_id": new_governance_uid(),
+            "schema_hash": "d" * 64,
+            "binding_hash": "1" * 64,
+            "access_mode": "write",
+            "write_mode": "merge",
+            "dialect": "postgresql",
+        },
+    }
+
+
+def _schedule():
+    return {
+        "schema_version": "1.0",
+        "timezone": "Asia/Shanghai",
+        "triggers": [{"type": "manual"}],
+        "max_concurrency": 1,
+        "timeout_seconds": 600,
+        "retry": {"max_attempts": 1, "delay_seconds": 5},
+        "conflict_policy": "queue",
+        "backfill": {"max_days": 1, "max_runs": 1},
+    }
+
+
+class FakeRepository:
+    def __init__(self):
+        self.release = _release()
+        binding = _binding()
+        for side in ("input", "output"):
+            binding[side]["binding_id"] = new_governance_uid()
+            binding[side]["logical_ref"] = side
+            binding[side]["schema_snapshot_id"] = self.release[
+                "schema_snapshots"
+            ][side]["id"]
+            binding[side]["schema_hash"] = self.release["schema_snapshots"][
+                side
+            ]["schema_hash"]
+        self.release["binding_snapshot"] = binding
+        self.deployments = {}
+        self.by_key = {}
+        self.evidence = {}
+        self.operations = {}
+        self.transitions = []
+
+    def load_deployable_release(self, version_id, environment=None):
+        assert version_id == VERSION_ID
+        assert environment == "production"
+        return deepcopy(self.release)
+
+    def create_deployment(self, snapshot):
+        key = (snapshot["actor_uid"], snapshot["environment"], snapshot["idempotency_key"])
+        if key in self.by_key:
+            return deepcopy(self.deployments[self.by_key[key]])
+        deployment = {
+            **deepcopy(snapshot),
+            "id": new_governance_uid(),
+            "status": "draft",
+            "lock_version": 0,
+            "previous_active_deployment_id": None,
+        }
+        self.deployments[deployment["id"]] = deployment
+        self.by_key[key] = deployment["id"]
+        return deepcopy(deployment)
+
+    def get_deployment(self, deployment_id, *, for_update=False):
+        del for_update
+        return deepcopy(self.deployments[deployment_id])
+
+    def claim_operation(
+        self,
+        deployment_id,
+        action,
+        key,
+        actor_uid,
+        correlation_id,
+        reason,
+        request_payload,
+    ):
+        op_key = (deployment_id, action, key)
+        if op_key in self.operations:
+            existing = self.operations[op_key]
+            if (
+                existing["actor_uid"] != actor_uid
+                or existing["reason"] != reason
+                or existing["request"] != request_payload
+            ):
+                raise ValueError(
+                    "deployment idempotency key is bound to another actor or request"
+                )
+            return False, deepcopy(existing)
+        operation = {
+            "id": new_governance_uid(),
+            "deployment_id": deployment_id,
+            "action": action,
+            "status": "claimed",
+            "actor_uid": actor_uid,
+            "correlation_id": correlation_id,
+            "reason": reason,
+            "request": deepcopy(request_payload),
+        }
+        self.operations[op_key] = operation
+        return True, deepcopy(operation)
+
+    def fail_operation(self, operation_id, error_code):
+        for value in self.operations.values():
+            if value["id"] == operation_id:
+                value.update(status="failed", error_code=error_code)
+
+    def mark_operation_unknown(self, operation_id, error_code):
+        for value in self.operations.values():
+            if value["id"] == operation_id:
+                value.update(status="unknown", error_code=error_code)
+
+    def commit(self):
+        return None
+
+    def get_operation(self, deployment_id, action, idempotency_key):
+        return deepcopy(
+            self.operations[(deployment_id, action, idempotency_key)]
+        )
+
+    def reclaim_unknown_operation(self, operation_id):
+        for value in self.operations.values():
+            if value["id"] == operation_id:
+                if value["status"] != "unknown":
+                    raise ValueError("unknown operation changed concurrently")
+                value["status"] = "claimed"
+                value["error_code"] = None
+                return
+        raise ValueError("operation missing")
+
+    def mark_disabled(self, deployment_id, expected_version, engine, operation_id):
+        current = self.deployments[deployment_id]
+        if current["lock_version"] != expected_version:
+            raise ValueError("deployment state changed concurrently")
+        current.update(
+            status="disabled",
+            lock_version=expected_version + 1,
+            engine_namespace=engine["namespace"],
+            engine_definition_id=engine["definition_id"],
+            engine_revision=engine["revision"],
+            engine_definition_hash=engine["definition_hash"],
+            deploy_receipt=engine["receipt"],
+        )
+        self._complete(operation_id, current)
+        return deepcopy(current)
+
+    def record_canary(self, deployment_id, expected_version, evidence, operation_id):
+        current = self.deployments[deployment_id]
+        if current["lock_version"] != expected_version:
+            raise ValueError("deployment state changed concurrently")
+        current.update(status="canary", lock_version=expected_version + 1)
+        self.evidence[evidence["id"]] = deepcopy(evidence)
+        self._complete(operation_id, evidence)
+        return deepcopy(evidence)
+
+    def get_canary_evidence(self, evidence_id):
+        return deepcopy(self.evidence[evidence_id])
+
+    def activate_atomic(self, deployment_id, expected_version, evidence_id, actor_uid, operation_id):
+        current = self.deployments[deployment_id]
+        if current["lock_version"] != expected_version:
+            raise ValueError("deployment state changed concurrently")
+        previous = next(
+            (
+                value
+                for value in self.deployments.values()
+                if value["dataflow_uid"] == current["dataflow_uid"]
+                and value["environment"] == current["environment"]
+                and value["status"] == "active"
+                and value["id"] != deployment_id
+            ),
+            None,
+        )
+        if previous:
+            previous.update(status="superseded", lock_version=previous["lock_version"] + 1)
+            current["previous_active_deployment_id"] = previous["id"]
+        current.update(
+            status="active",
+            lock_version=expected_version + 1,
+            activated_by=actor_uid,
+            canary_evidence_id=evidence_id,
+        )
+        self._complete(operation_id, current)
+        return deepcopy(current)
+
+    def rollback_atomic(self, deployment_id, expected_version, actor_uid, operation_id):
+        current = self.deployments[deployment_id]
+        if current["lock_version"] != expected_version:
+            raise ValueError("deployment state changed concurrently")
+        previous = self.deployments[current["previous_active_deployment_id"]]
+        current.update(status="rolled_back", lock_version=expected_version + 1)
+        previous.update(status="active", lock_version=previous["lock_version"] + 1)
+        self._complete(operation_id, previous)
+        return {"rolled_back": deepcopy(current), "active": deepcopy(previous)}
+
+    def list_deployments(self, environment=None):
+        values = self.deployments.values()
+        return [
+            deepcopy(value)
+            for value in values
+            if environment is None or value["environment"] == environment
+        ]
+
+    def _complete(self, operation_id, result):
+        for value in self.operations.values():
+            if value["id"] == operation_id:
+                value.update(status="completed", result=deepcopy(result))
+
+
+class FakeCompiler:
+    def __call__(self, workflow_spec, schedule_plan, environment, version_no):
+        del workflow_spec, schedule_plan, version_no
+
+        class Compiled:
+            namespace = f"dataops.{environment}"
+            flow_id = f"df_{FLOW_UID.replace('-', '')}_v7"
+            definition_hash = "9" * 64
+            yaml = "id: flow\ndisabled: true\n"
+
+        return Compiled()
+
+
+class FakeEngine:
+    def __init__(self):
+        self.calls = []
+        self.fail = None
+        self.flows = {}
+
+    def deploy_disabled(self, definition):
+        self.calls.append(("deploy_disabled", definition))
+        if self.fail == "deploy":
+            raise RuntimeError("authorization=secret")
+        if self.fail == "deploy_response_lost":
+            flow_id = f"df_{FLOW_UID.replace('-', '')}_v7"
+            self.flows[("dataops.production", flow_id)] = {
+                "id": flow_id,
+                "namespace": "dataops.production",
+                "disabled": True,
+                "revision": 3,
+                "labels": [
+                    {"key": "dataflow_uid", "value": FLOW_UID},
+                    {"key": "environment", "value": "production"},
+                    {"key": "workflow_version", "value": "7"},
+                ],
+            }
+            raise requests.Timeout("response lost")
+        flow_id = f"df_{FLOW_UID.replace('-', '')}_v7"
+        self.flows[("dataops.production", flow_id)] = {
+            "id": flow_id,
+            "namespace": "dataops.production",
+            "disabled": True,
+        }
+        return {"revision": 3, "id": "server-receipt"}
+
+    def activate(self, namespace, definition_id):
+        self.calls.append(("activate", namespace, definition_id))
+        if self.fail == "activate":
+            raise RuntimeError("engine activate failed")
+        flow = self.flows.get((namespace, definition_id))
+        if flow is not None:
+            flow["disabled"] = False
+        if self.fail == "rollback_response_lost":
+            self.fail = None
+            raise requests.Timeout("rollback response lost")
+        return {"status": "enabled"}
+
+    def deactivate(self, namespace, definition_id):
+        self.calls.append(("deactivate", namespace, definition_id))
+        flow = self.flows.get((namespace, definition_id))
+        if flow is not None:
+            flow["disabled"] = True
+        return {"status": "disabled"}
+
+    def execute(self, namespace, definition_id, inputs=None):
+        self.calls.append(("execute", namespace, definition_id, inputs))
+        return {"id": "execution-1"}
+
+    def get_execution(self, execution_id):
+        self.calls.append(("get_execution", execution_id))
+        return {"id": execution_id, "state": {"current": "SUCCESS"}}
+
+    def get_flow(self, namespace, definition_id):
+        self.calls.append(("get_flow", namespace, definition_id))
+        return deepcopy(self.flows[(namespace, definition_id)])
+
+
+def _service(repository=None, engine=None, now=None):
+    from app.core.data_rules.deployment import DataFlowDeploymentService
+
+    return DataFlowDeploymentService(
+        repository or FakeRepository(),
+        engine=engine or FakeEngine(),
+        compiler=FakeCompiler(),
+        clock=(lambda: now) if now else None,
+    )
+
+
+def _create(service, binding=None, key="create-1"):
+    if binding is None:
+        binding = deepcopy(service.repository.release["binding_snapshot"])
+    return service.create(
+        VERSION_ID,
+        binding_snapshot=binding,
+        environment="production",
+        schedule_plan=_schedule(),
+        actor_uid=ACTOR,
+        reason="first production rollout",
+        idempotency_key=key,
+    )
+
+
+def test_production_deployment_requires_disabled_deploy_and_passed_canary():
+    repository = FakeRepository()
+    engine = FakeEngine()
+    service = _service(repository, engine)
+    deployment = _create(service)
+    disabled = service.deploy_disabled(
+        deployment["id"], ACTOR, idempotency_key="deploy-1"
+    )
+
+    with pytest.raises(ValueError, match="passed canary"):
+        service.activate(
+            disabled["id"], None, ACTOR, idempotency_key="activate-1"
+        )
+
+    evidence = service.run_canary(
+        disabled["id"],
+        {"biz_date": "2026-07-24"},
+        ACTOR,
+        idempotency_key="canary-1",
+    )
+    active = service.activate(
+        disabled["id"], evidence["id"], ACTOR, idempotency_key="activate-2"
+    )
+
+    assert active["status"] == "active"
+    assert active["package_hash"] == "a" * 64
+    assert active["binding_hash"]
+    assert active["workflow_spec_hash"]
+    assert [call[0] for call in engine.calls].count("activate") == 2
+
+
+def test_create_is_idempotent_and_rejects_inline_semantics_or_credentials():
+    service = _service()
+    first = _create(service, key="same")
+    replay = _create(service, key="same")
+    assert replay["id"] == first["id"]
+
+    unsafe = _binding()
+    unsafe["output"]["sql"] = "delete from customer"
+    with pytest.raises(ValueError, match="unsupported|inline"):
+        _create(service, unsafe, key="unsafe")
+
+    unsafe = _binding()
+    unsafe["input"]["password"] = "secret"
+    with pytest.raises(ValueError, match="unsupported|credential"):
+        _create(service, unsafe, key="credential")
+
+    tampered = deepcopy(service.repository.release["binding_snapshot"])
+    tampered["input"]["object_ref"] = "attacker.customer"
+    tampered["input"]["binding_hash"] = "2" * 64
+    with pytest.raises(ValueError, match="binding selection has drifted"):
+        _create(service, tampered, key="binding-tamper")
+
+    selected = {
+        side: {
+            "binding_id": service.repository.release["binding_snapshot"][side][
+                "binding_id"
+            ]
+        }
+        for side in ("input", "output")
+    }
+    selected_deployment = _create(service, selected, key="binding-id-only")
+    assert (
+        selected_deployment["binding_snapshot"]["input"]["object_ref"]
+        == "landing.customer"
+    )
+
+
+def test_activation_rejects_expired_or_drifted_server_owned_evidence():
+    now = datetime(2026, 7, 24, 8, tzinfo=UTC)
+    repository = FakeRepository()
+    service = _service(repository, now=now)
+    deployment = _create(service)
+    disabled = service.deploy_disabled(
+        deployment["id"], ACTOR, idempotency_key="deploy"
+    )
+    evidence = service.run_canary(
+        disabled["id"],
+        {"biz_date": "2026-07-24"},
+        ACTOR,
+        idempotency_key="canary",
+    )
+    repository.evidence[evidence["id"]]["expires_at"] = (
+        now - timedelta(seconds=1)
+    ).isoformat()
+    with pytest.raises(ValueError, match="expired"):
+        service.activate(
+            disabled["id"], evidence["id"], ACTOR, idempotency_key="expired"
+        )
+
+    repository.evidence[evidence["id"]]["expires_at"] = (
+        now + timedelta(minutes=5)
+    ).isoformat()
+    repository.evidence[evidence["id"]]["package_hash"] = "0" * 64
+    with pytest.raises(ValueError, match="drift"):
+        service.activate(
+            disabled["id"], evidence["id"], ACTOR, idempotency_key="drift"
+        )
+
+
+def test_activation_supersedes_previous_and_rollback_restores_exact_snapshot():
+    repository = FakeRepository()
+    engine = FakeEngine()
+    service = _service(repository, engine)
+
+    previous = _create(service, key="previous")
+    previous = service.deploy_disabled(
+        previous["id"], ACTOR, idempotency_key="deploy-previous"
+    )
+    previous_evidence = service.run_canary(
+        previous["id"],
+        {"biz_date": "2026-07-23"},
+        ACTOR,
+        idempotency_key="canary-previous",
+    )
+    previous = service.activate(
+        previous["id"],
+        previous_evidence["id"],
+        ACTOR,
+        idempotency_key="activate-previous",
+    )
+    previous_snapshot = deepcopy(previous)
+
+    candidate = _create(service, key="candidate")
+    candidate = service.deploy_disabled(
+        candidate["id"], ACTOR, idempotency_key="deploy-candidate"
+    )
+    candidate_evidence = service.run_canary(
+        candidate["id"],
+        {"biz_date": "2026-07-24"},
+        ACTOR,
+        idempotency_key="canary-candidate",
+    )
+    candidate = service.activate(
+        candidate["id"],
+        candidate_evidence["id"],
+        ACTOR,
+        idempotency_key="activate-candidate",
+    )
+    result = service.rollback(
+        candidate["id"], ACTOR, idempotency_key="rollback-candidate"
+    )
+
+    assert result["rolled_back"]["status"] == "rolled_back"
+    assert result["active"]["id"] == previous_snapshot["id"]
+    assert result["active"]["package_hash"] == previous_snapshot["package_hash"]
+    assert result["active"]["binding_hash"] == previous_snapshot["binding_hash"]
+    # Candidate is disabled before the exact previous flow is re-enabled.
+    assert engine.calls[-2][0] == "deactivate"
+    assert engine.calls[-1][0] == "activate"
+
+
+def test_engine_failure_is_redacted_and_operation_can_be_retried():
+    repository = FakeRepository()
+    engine = FakeEngine()
+    service = _service(repository, engine)
+    deployment = _create(service)
+    engine.fail = "deploy"
+
+    with pytest.raises(RuntimeError, match="deployment engine operation failed"):
+        service.deploy_disabled(
+            deployment["id"], ACTOR, idempotency_key="deploy-fail"
+        )
+    assert "secret" not in str(repository.operations)
+
+    engine.fail = None
+    disabled = service.deploy_disabled(
+        deployment["id"], ACTOR, idempotency_key="deploy-retry"
+    )
+    assert disabled["status"] == "disabled"
+
+
+def test_optimistic_lock_rejects_stale_transition():
+    repository = FakeRepository()
+    service = _service(repository)
+    deployment = _create(service)
+    repository.deployments[deployment["id"]]["lock_version"] = 2
+    stale = deepcopy(deployment)
+    repository.get_deployment = lambda deployment_id, for_update=False: stale
+
+    with pytest.raises(ValueError, match="concurrently"):
+        service.deploy_disabled(
+            deployment["id"], ACTOR, idempotency_key="stale"
+        )
+    operation = next(iter(repository.operations.values()))
+    assert operation["status"] == "failed"
+
+
+def test_response_loss_is_persisted_unknown_and_reconciled_without_redeploy():
+    repository = FakeRepository()
+    engine = FakeEngine()
+    service = _service(repository, engine)
+    deployment = _create(service)
+    engine.fail = "deploy_response_lost"
+
+    with pytest.raises(RuntimeError, match="unknown"):
+        service.deploy_disabled(
+            deployment["id"], ACTOR, idempotency_key="response-loss"
+        )
+    operation = repository.operations[
+        (deployment["id"], "deploy_disabled", "response-loss")
+    ]
+    assert operation["status"] == "unknown"
+
+    engine.fail = None
+    reconciled = service.reconcile(
+        deployment["id"],
+        "deploy_disabled",
+        ACTOR,
+        idempotency_key="response-loss",
+    )
+    assert reconciled["status"] == "disabled"
+    assert [call[0] for call in engine.calls].count("deploy_disabled") == 1
+    assert operation["status"] == "completed"
+
+
+def test_idempotency_key_is_bound_to_actor_reason_and_canary_inputs():
+    repository = FakeRepository()
+    service = _service(repository)
+    deployment = _create(service)
+    deployment = service.deploy_disabled(
+        deployment["id"], ACTOR, idempotency_key="deploy"
+    )
+    service.run_canary(
+        deployment["id"],
+        {"biz_date": "2026-07-24"},
+        ACTOR,
+        idempotency_key="canary-same",
+    )
+    with pytest.raises(ValueError, match="another actor or request"):
+        service.run_canary(
+            deployment["id"],
+            {"biz_date": "2026-07-25"},
+            ACTOR,
+            idempotency_key="canary-same",
+        )
+    with pytest.raises(ValueError, match="another actor or request"):
+        service.deploy_disabled(
+            deployment["id"],
+            new_governance_uid(),
+            idempotency_key="deploy",
+        )
+
+
+def test_rollback_response_loss_reconciles_observed_partial_engine_state():
+    repository = FakeRepository()
+    engine = FakeEngine()
+    service = _service(repository, engine)
+    previous = _create(service, key="rollback-reconcile-previous")
+    candidate = _create(service, key="rollback-reconcile-candidate")
+    previous.update(
+        status="superseded",
+        lock_version=1,
+        engine_namespace="dataops.production",
+        engine_definition_id="previous-flow",
+    )
+    candidate.update(
+        status="active",
+        lock_version=3,
+        previous_active_deployment_id=previous["id"],
+        engine_namespace="dataops.production",
+        engine_definition_id="candidate-flow",
+    )
+    repository.deployments[previous["id"]] = deepcopy(previous)
+    repository.deployments[candidate["id"]] = deepcopy(candidate)
+    engine.flows[("dataops.production", "previous-flow")] = {
+        "disabled": True
+    }
+    engine.flows[("dataops.production", "candidate-flow")] = {
+        "disabled": False
+    }
+    engine.fail = "rollback_response_lost"
+
+    with pytest.raises(RuntimeError, match="unknown"):
+        service.rollback(
+            candidate["id"],
+            ACTOR,
+            idempotency_key="rollback-response-loss",
+        )
+    operation = repository.operations[
+        (candidate["id"], "rollback", "rollback-response-loss")
+    ]
+    assert operation["status"] == "unknown"
+
+    result = service.reconcile(
+        candidate["id"],
+        "rollback",
+        ACTOR,
+        idempotency_key="rollback-response-loss",
+    )
+    assert result["rolled_back"]["status"] == "rolled_back"
+    assert result["active"]["id"] == previous["id"]
+    assert engine.flows[("dataops.production", "candidate-flow")][
+        "disabled"
+    ] is True
+    assert engine.flows[("dataops.production", "previous-flow")][
+        "disabled"
+    ] is False

+ 314 - 0
tests/integration/test_data_factory_postgres_lifecycle.py

@@ -0,0 +1,314 @@
+from __future__ import annotations
+
+import json
+
+import pytest
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import Session
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_rules.repository import DataRuleRepository
+
+pytestmark = pytest.mark.integration
+DATABASE_URL = (
+    "postgresql://dataops:dataops-test-password@127.0.0.1:15432/dataops"
+)
+
+
+def _insert_deployment(
+    connection,
+    *,
+    deployment_id: str,
+    version_id: str,
+    dataflow_uid: str,
+    actor_uid: str,
+    status: str,
+    idempotency_key: str,
+) -> None:
+    hashes = {
+        "package_hash": "a" * 64,
+        "binding_hash": "b" * 64,
+        "schema_snapshot_hash": "c" * 64,
+        "workflow_spec_hash": "d" * 64,
+        "schedule_hash": "e" * 64,
+        "engine_definition_hash": "f" * 64,
+    }
+    connection.execute(
+        text(
+            """
+            INSERT INTO public.dataflow_deployments
+            (id, dataflow_version_id, dataflow_uid, environment, status,
+             package, package_hash, binding_snapshot, binding_hash,
+             schema_snapshots, schema_snapshot_hash, physical_plan_hashes,
+             workflow_spec, workflow_spec_hash, schedule_snapshot,
+             schedule_hash, engine_namespace, engine_definition_id,
+             engine_definition_hash, created_by, create_reason,
+             create_idempotency_key, correlation_id)
+            VALUES
+            (CAST(:id AS uuid), CAST(:version_id AS uuid),
+             CAST(:dataflow_uid AS uuid), 'production', :status,
+             CAST(:package AS jsonb), :package_hash,
+             CAST(:binding AS jsonb), :binding_hash,
+             CAST(:schemas AS jsonb), :schema_snapshot_hash,
+             CAST(:plans AS jsonb), CAST(:workflow AS jsonb),
+             :workflow_spec_hash, CAST(:schedule AS jsonb), :schedule_hash,
+             'dataops.factory', :definition_id, :engine_definition_hash,
+             CAST(:actor_uid AS uuid), 'integration acceptance', :key,
+             CAST(:correlation_id AS uuid))
+            """
+        ),
+        {
+            "id": deployment_id,
+            "version_id": version_id,
+            "dataflow_uid": dataflow_uid,
+            "status": status,
+            "package": json.dumps({"schema_version": "1.0"}),
+            "binding": json.dumps(
+                {"input": {"binding_id": "in"}, "output": {"binding_id": "out"}}
+            ),
+            "schemas": json.dumps({"input": {}, "output": {}}),
+            "plans": json.dumps(["0" * 64]),
+            "workflow": json.dumps(
+                {
+                    "schema_version": "1.0",
+                    "dataflow_uid": dataflow_uid,
+                    "name": "factory postgres acceptance",
+                    "nodes": [],
+                    "edges": [],
+                    "parameters": {},
+                }
+            ),
+            "schedule": json.dumps(
+                {
+                    "schema_version": "1.0",
+                    "timezone": "Asia/Shanghai",
+                    "triggers": [{"type": "manual"}],
+                    "max_concurrency": 1,
+                    "timeout_seconds": 60,
+                    "retry": {"max_attempts": 1, "delay_seconds": 0},
+                    "conflict_policy": "queue",
+                    "backfill": {"max_days": 1, "max_runs": 1},
+                }
+            ),
+            "definition_id": f"flow-{version_id}",
+            "actor_uid": actor_uid,
+            "key": idempotency_key,
+            "correlation_id": new_governance_uid(),
+            **hashes,
+        },
+    )
+
+
+def test_postgres_activation_and_rollback_are_atomic_and_idempotent():
+    engine = create_engine(DATABASE_URL, pool_pre_ping=True)
+    actor_uid = new_governance_uid()
+    dataflow_uid = new_governance_uid()
+    version_ids = [new_governance_uid(), new_governance_uid()]
+    deployment_ids = [new_governance_uid(), new_governance_uid()]
+    evidence_id = new_governance_uid()
+    operation_ids: list[str] = []
+    try:
+        with engine.begin() as connection:
+            connection.execute(
+                text(
+                    "INSERT INTO public.users "
+                    "(id, username, password_hash) "
+                    "VALUES (CAST(:id AS uuid), :username, 'not-used')"
+                ),
+                {"id": actor_uid, "username": f"factory-{actor_uid}"},
+            )
+            for index, version_id in enumerate(version_ids, start=1):
+                connection.execute(
+                    text(
+                        """
+                        INSERT INTO public.dataflow_versions
+                        (id, dataflow_uid, version_no, name, dataflow_spec,
+                         input_schema_hashes, output_schema_hash, status,
+                         created_by)
+                        VALUES
+                        (CAST(:id AS uuid), CAST(:uid AS uuid), :version,
+                         'factory postgres acceptance', '{}'::jsonb,
+                         '{}'::jsonb, :hash, 'released',
+                         CAST(:actor AS uuid))
+                        """
+                    ),
+                    {
+                        "id": version_id,
+                        "uid": dataflow_uid,
+                        "version": index,
+                        "hash": "9" * 64,
+                        "actor": actor_uid,
+                    },
+                )
+            _insert_deployment(
+                connection,
+                deployment_id=deployment_ids[0],
+                version_id=version_ids[0],
+                dataflow_uid=dataflow_uid,
+                actor_uid=actor_uid,
+                status="active",
+                idempotency_key="factory-pg-create-v1",
+            )
+            _insert_deployment(
+                connection,
+                deployment_id=deployment_ids[1],
+                version_id=version_ids[1],
+                dataflow_uid=dataflow_uid,
+                actor_uid=actor_uid,
+                status="canary",
+                idempotency_key="factory-pg-create-v2",
+            )
+            connection.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,
+                     verified_by, expires_at)
+                    VALUES
+                    (CAST(:id AS uuid), CAST(:deployment AS uuid), 'exec-pg',
+                     'passed', :package_hash, :binding_hash, :schema_hash,
+                     CAST(:plans AS jsonb), :workflow_hash, :schedule_hash,
+                     :engine_hash, CAST(:actor AS uuid),
+                     CURRENT_TIMESTAMP + INTERVAL '15 minutes')
+                    """
+                ),
+                {
+                    "id": evidence_id,
+                    "deployment": deployment_ids[1],
+                    "actor": actor_uid,
+                    "package_hash": "a" * 64,
+                    "binding_hash": "b" * 64,
+                    "schema_hash": "c" * 64,
+                    "plans": json.dumps(["0" * 64]),
+                    "workflow_hash": "d" * 64,
+                    "schedule_hash": "e" * 64,
+                    "engine_hash": "f" * 64,
+                },
+            )
+            connection.execute(
+                text(
+                    "UPDATE public.dataflow_deployments "
+                    "SET canary_evidence_id = CAST(:evidence AS uuid) "
+                    "WHERE id = CAST(:id AS uuid)"
+                ),
+                {"evidence": evidence_id, "id": deployment_ids[1]},
+            )
+
+        with Session(engine) as session:
+            repository = DataRuleRepository(session)
+            claimed, activation = repository.claim_operation(
+                deployment_ids[1],
+                "activate",
+                "factory-pg-activate",
+                actor_uid,
+                new_governance_uid(),
+                "activate acceptance candidate",
+                {"evidence_id": evidence_id},
+            )
+            assert claimed is True
+            operation_ids.append(activation["id"])
+            active = repository.activate_atomic(
+                deployment_ids[1],
+                0,
+                evidence_id,
+                actor_uid,
+                activation["id"],
+            )
+            session.commit()
+            assert active["status"] == "active"
+            assert (
+                active["previous_active_deployment_id"]
+                == deployment_ids[0]
+            )
+            assert repository.get_deployment(deployment_ids[0])[
+                "status"
+            ] == "superseded"
+
+            claimed, rollback = repository.claim_operation(
+                deployment_ids[1],
+                "rollback",
+                "factory-pg-rollback",
+                actor_uid,
+                new_governance_uid(),
+                "rollback acceptance candidate",
+                {"candidate_deployment_id": deployment_ids[1]},
+            )
+            assert claimed is True
+            operation_ids.append(rollback["id"])
+            restored = repository.rollback_atomic(
+                deployment_ids[1],
+                active["lock_version"],
+                actor_uid,
+                rollback["id"],
+            )
+            session.commit()
+            assert restored["rolled_back"]["status"] == "rolled_back"
+            assert restored["active"]["id"] == deployment_ids[0]
+            assert restored["active"]["status"] == "active"
+
+            replayed, prior = repository.claim_operation(
+                deployment_ids[1],
+                "rollback",
+                "factory-pg-rollback",
+                actor_uid,
+                rollback["correlation_id"],
+                "rollback acceptance candidate",
+                {"candidate_deployment_id": deployment_ids[1]},
+            )
+            assert replayed is False
+            assert prior["status"] == "completed"
+            assert prior["result"]["active"]["id"] == deployment_ids[0]
+            session.rollback()
+    finally:
+        with engine.begin() as connection:
+            connection.execute(
+                text(
+                    "UPDATE public.dataflow_deployments "
+                    "SET canary_evidence_id = NULL, "
+                    "previous_active_deployment_id = NULL "
+                    "WHERE id = ANY(CAST(:ids AS uuid[]))"
+                ),
+                {"ids": deployment_ids},
+            )
+            connection.execute(
+                text(
+                    "DELETE FROM public.dataflow_deployment_transitions "
+                    "WHERE deployment_id = ANY(CAST(:ids AS uuid[]))"
+                ),
+                {"ids": deployment_ids},
+            )
+            connection.execute(
+                text(
+                    "DELETE FROM public.dataflow_deployment_operations "
+                    "WHERE deployment_id = ANY(CAST(:ids AS uuid[]))"
+                ),
+                {"ids": deployment_ids},
+            )
+            connection.execute(
+                text(
+                    "DELETE FROM public.dataflow_canary_evidence "
+                    "WHERE deployment_id = ANY(CAST(:ids AS uuid[]))"
+                ),
+                {"ids": deployment_ids},
+            )
+            connection.execute(
+                text(
+                    "DELETE FROM public.dataflow_deployments "
+                    "WHERE id = ANY(CAST(:ids AS uuid[]))"
+                ),
+                {"ids": deployment_ids},
+            )
+            connection.execute(
+                text(
+                    "DELETE FROM public.dataflow_versions "
+                    "WHERE id = ANY(CAST(:ids AS uuid[]))"
+                ),
+                {"ids": version_ids},
+            )
+            connection.execute(
+                text("DELETE FROM public.users WHERE id = CAST(:id AS uuid)"),
+                {"id": actor_uid},
+            )
+        engine.dispose()

+ 135 - 0
tests/test_data_rule_api.py

@@ -180,6 +180,48 @@ class FakeReleaseService:
         }
 
 
+class FakeDeploymentService:
+    def __init__(self):
+        self.calls = []
+        self.deployment_id = new_governance_uid()
+        self.evidence_id = new_governance_uid()
+
+    def create(self, version_id, **kwargs):
+        self.calls.append(("create", version_id, kwargs))
+        return {"id": self.deployment_id, "status": "draft"}
+
+    def deploy_disabled(self, deployment_id, actor_uid, **kwargs):
+        self.calls.append(("deploy_disabled", deployment_id, actor_uid, kwargs))
+        return {"id": deployment_id, "status": "disabled"}
+
+    def run_canary(self, deployment_id, inputs, actor_uid, **kwargs):
+        self.calls.append(
+            ("run_canary", deployment_id, inputs, actor_uid, kwargs)
+        )
+        return {
+            "id": self.evidence_id,
+            "deployment_id": deployment_id,
+            "status": "passed",
+        }
+
+    def activate(self, deployment_id, evidence_id, actor_uid, **kwargs):
+        self.calls.append(
+            ("activate", deployment_id, evidence_id, actor_uid, kwargs)
+        )
+        return {"id": deployment_id, "status": "active"}
+
+    def rollback(self, deployment_id, actor_uid, **kwargs):
+        self.calls.append(("rollback", deployment_id, actor_uid, kwargs))
+        return {
+            "rolled_back": {"id": deployment_id, "status": "rolled_back"},
+            "active": {"id": new_governance_uid(), "status": "active"},
+        }
+
+    def list(self, environment=None):
+        self.calls.append(("list", environment))
+        return [{"id": self.deployment_id, "status": "draft"}]
+
+
 class FakePublicationService:
     def __init__(self, repository):
         self.repository = repository
@@ -949,6 +991,99 @@ def test_rule_gates_reject_caller_supplied_compile_or_test_evidence(
     assert repository.calls == []
 
 
+def test_data_factory_lifecycle_routes_are_closed_and_independently_governed(
+    monkeypatch,
+):
+    from app import create_app
+
+    app = create_app()
+    _use_token_identity(monkeypatch)
+    app.config["TESTING"] = True
+    app.config["DATA_FACTORY_ACTIVATION_ENABLED"] = True
+    service = FakeDeploymentService()
+    app.extensions["dataflow_deployment_service"] = service
+    client = app.test_client()
+    admin = _headers(app, "admin")
+    version_id = new_governance_uid()
+    binding_id = new_governance_uid()
+
+    created = client.post(
+        "/api/rules/deployments",
+        json={
+            "dataflow_version_id": version_id,
+            "environment": "production",
+            "binding_snapshot": {
+                "input": {"binding_id": binding_id},
+                "output": {"binding_id": new_governance_uid()},
+            },
+            "schedule_plan": {"schema_version": "1.0"},
+            "reason": "production rollout",
+            "idempotency_key": "create-1",
+        },
+        headers=admin,
+    )
+    assert created.status_code == 201
+    deployment_id = created.get_json()["data"]["id"]
+
+    disabled = client.post(
+        f"/api/rules/deployments/{deployment_id}/deploy-disabled",
+        json={"reason": "disabled first", "idempotency_key": "deploy-1"},
+        headers=admin,
+    )
+    assert disabled.status_code == 200
+    canary = client.post(
+        f"/api/rules/deployments/{deployment_id}/canary",
+        json={
+            "inputs": {"biz_date": "2026-07-24"},
+            "reason": "trial production",
+            "idempotency_key": "canary-1",
+        },
+        headers=admin,
+    )
+    assert canary.status_code == 200
+    evidence_id = canary.get_json()["data"]["id"]
+    active = client.post(
+        f"/api/rules/deployments/{deployment_id}/activate",
+        json={
+            "evidence_id": evidence_id,
+            "reason": "mass production",
+            "idempotency_key": "activate-1",
+        },
+        headers=admin,
+    )
+    assert active.status_code == 200
+    rolled_back = client.post(
+        f"/api/rules/deployments/{deployment_id}/rollback",
+        json={"reason": "restore stable", "idempotency_key": "rollback-1"},
+        headers=admin,
+    )
+    assert rolled_back.status_code == 200
+
+    forbidden = client.post(
+        f"/api/rules/deployments/{deployment_id}/canary",
+        json={"inputs": {}, "idempotency_key": "viewer"},
+        headers=_headers(app, "viewer"),
+    )
+    assert forbidden.status_code == 403
+    inline_code = client.post(
+        f"/api/rules/deployments/{deployment_id}/canary",
+        json={
+            "inputs": {},
+            "idempotency_key": "unsafe",
+            "sql": "delete from customer",
+        },
+        headers=admin,
+    )
+    assert inline_code.status_code == 409
+    assert [call[0] for call in service.calls] == [
+        "create",
+        "deploy_disabled",
+        "run_canary",
+        "activate",
+        "rollback",
+    ]
+
+
 def test_create_rule_version_rejects_legacy_v1_payload_before_repository(
     monkeypatch,
 ):

+ 31 - 8
tests/test_data_rule_frontend_contract.py

@@ -38,7 +38,11 @@ def test_rule_api_client_exposes_versions_publish_and_server_side_release():
     assert "http.post('/rules/standard-versions'" in source
     assert "http.post(`/rules/standard-versions/${versionId}/publish`)" in source
     assert "http.post(`/rules/production-lines/${dataflowUid}/release`" in source
-    assert "/activate" not in source
+    assert "listDataFlowDeployments" in source
+    assert "/deploy-disabled" in source
+    assert "/canary" in source
+    assert "/activate" in source
+    assert "/rollback" in source
 
 
 def test_reusable_authoring_panel_preserves_human_review_gate():
@@ -253,16 +257,35 @@ def test_standard_authoring_does_not_forge_validated_state_client_side():
     assert "先发布规则,再在标准条款中绑定" in source
 
 
-def test_data_factory_exposes_release_ready_but_no_fake_activation():
+def test_data_factory_exposes_governed_trial_activation_and_rollback():
     page = FACTORY.read_text(encoding="utf-8")
     index = FACTORY_INDEX.read_text(encoding="utf-8")
 
     assert "数据生产线投产" in page
     assert "getRuleCapabilities" in page
-    assert "production_line_release" in page
-    assert "data_factory_activation" in page
-    assert "发布版本" in page
-    assert "执行计划" in page
-    assert "发布与执行计划已经可用" in page
-    assert "投产激活仍保持关闭" in page
+    assert "data_factory_readiness" in page
+    assert "listDataFlowDeployments" in page
+    assert "listDataFlowDeploymentCandidates" in page
+    assert "createDataFlowDeployment" in page
+    assert "deployDataFlowDisabled" in page
+    assert "runDataFlowCanary" in page
+    assert "activateDataFlowDeployment" in page
+    assert "rollbackDataFlowDeployment" in page
+    assert "reconcileDataFlowDeploy" in page
+    assert "reconcileDataFlowActivate" in page
+    assert "reconcileDataFlowRollback" in page
+    assert "dataflows:deploy" in page
+    assert "dataflows:canary" in page
+    assert "rules:execute" in page
+    assert "dataflows:activate" in page
+    assert "dataflows:rollback" in page
+    assert "生产线包 Hash" in page
+    assert "试生产证据" in page
+    assert "状态履历" in page
+    assert "生产线入厂" in page
+    assert "window.localStorage.getItem(storageKey)" in page
+    assert "window.localStorage.setItem(storageKey, value)" in page
+    assert "Number(error?.code) === 409" in page
+    assert "unknown_operations" in page
+    assert "v-dialog" in page
     assert "ProductionLineDeployment" in index

+ 28 - 2
tests/test_kestra_local_contract.py

@@ -2,11 +2,14 @@ import json
 import subprocess
 from pathlib import Path
 
-
 ROOT = Path(__file__).resolve().parents[1]
 COMPOSE = ROOT / "deploy/docker/docker-compose.yml"
 KESTRA_CONFIG = ROOT / "deploy/docker/kestra/application.yml"
 POSTGRES_BOOTSTRAP = ROOT / "deploy/docker/postgres/init/000-init.sql"
+KESTRA_BOOTSTRAP = (
+    ROOT / "deploy/docker/postgres/init/010-kestra-runtime.sql"
+)
+KESTRA_SOAK = ROOT / "scripts/validation/kestra_queue_soak.sh"
 
 
 def _compose_config():
@@ -64,6 +67,14 @@ def test_kestra_uses_its_own_database_and_mcp_has_a_restricted_tool_surface():
 
     assert kestra_environment["KESTRA_DB_NAME"] == "kestra"
     assert kestra_environment["KESTRA_DB_HOST"] == "postgres"
+    assert kestra_environment["KESTRA_DB_USERNAME"] == "kestra_runtime"
+    assert config["services"]["kestra"]["restart"] == "unless-stopped"
+    assert (
+        config["services"]["kestra"]["depends_on"]["kestra-db-init"][
+            "condition"
+        ]
+        == "service_completed_successfully"
+    )
     assert mcp_environment["KESTRA_BASE_URL"] == "http://kestra:8080/api/v1"
     assert mcp_environment["KESTRA_TENANT_ID"] == "main"
     assert mcp_environment["KESTRA_MCP_LOG_LEVEL"] == "ERROR"
@@ -96,7 +107,13 @@ def test_kestra_configuration_and_fresh_database_bootstrap_are_present():
     source = KESTRA_CONFIG.read_text(encoding="utf-8")
     assert "datasources:" in source
     assert "postgres:" in source
-    assert "url: jdbc:postgresql://${KESTRA_DB_HOST}:5432/${KESTRA_DB_NAME}" in source
+    assert (
+        "url: jdbc:postgresql://${KESTRA_DB_HOST}:5432/"
+        "${KESTRA_DB_NAME}?tcpKeepAlive=true"
+    ) in source
+    assert "maximum-pool-size: 20" in source
+    assert "minimum-idle: 4" in source
+    assert "connection-test-query: SELECT 1" in source
     assert "basic-auth:" in source
     assert "${KESTRA_USERNAME}" in source
     assert "${KESTRA_PASSWORD}" in source
@@ -106,6 +123,15 @@ def test_kestra_configuration_and_fresh_database_bootstrap_are_present():
 
     bootstrap = POSTGRES_BOOTSTRAP.read_text(encoding="utf-8")
     assert "CREATE DATABASE kestra" in bootstrap
+    dedicated = KESTRA_BOOTSTRAP.read_text(encoding="utf-8")
+    assert "CREATE ROLE kestra_runtime LOGIN" in dedicated
+    assert "ALTER DATABASE kestra OWNER TO kestra_runtime" in dedicated
+    assert "pg_catalog" in dedicated
+
+    soak = KESTRA_SOAK.read_text(encoding="utf-8")
+    assert "http://127.0.0.1:8081/health" in soak
+    assert "RestartCount" in soak
+    assert "broken pipe" in soak
 
 
 def test_kestra_mcp_is_documented_as_stdio_not_an_http_endpoint():

+ 36 - 0
tests/test_permission_matrix.py

@@ -81,3 +81,39 @@ def test_data_rule_routes_separate_read_authoring_and_release_permissions():
     assert permission_for_request(
         "/api/rules/production-lines/id/release", "POST"
     ) == (DATAFLOW_RELEASE,)
+
+
+def test_data_factory_actions_have_independent_permissions():
+    from app.core.system.permissions import (
+        DATAFLOW_ACTIVATE,
+        DATAFLOW_CANARY,
+        DATAFLOW_DEPLOY,
+        DATAFLOW_ROLLBACK,
+        RULES_EXECUTE,
+        permission_for_request,
+    )
+
+    assert permission_for_request("/api/rules/deployments", "POST") == (
+        DATAFLOW_DEPLOY,
+    )
+    assert permission_for_request(
+        "/api/rules/deployments/id/deploy-disabled", "POST"
+    ) == (DATAFLOW_DEPLOY,)
+    assert permission_for_request(
+        "/api/rules/deployments/id/canary", "POST"
+    ) == (DATAFLOW_CANARY, RULES_EXECUTE)
+    assert permission_for_request(
+        "/api/rules/deployments/id/activate", "POST"
+    ) == (DATAFLOW_ACTIVATE,)
+    assert permission_for_request(
+        "/api/rules/deployments/id/rollback", "POST"
+    ) == (DATAFLOW_ROLLBACK,)
+    assert permission_for_request(
+        "/api/rules/deployments/id/reconcile-deploy", "POST"
+    ) == (DATAFLOW_DEPLOY,)
+    assert permission_for_request(
+        "/api/rules/deployments/id/reconcile-activate", "POST"
+    ) == (DATAFLOW_ACTIVATE,)
+    assert permission_for_request(
+        "/api/rules/deployments/id/reconcile-rollback", "POST"
+    ) == (DATAFLOW_ROLLBACK,)