Просмотр исходного кода

feat: govern data product lifecycle

马小龙 2 недель назад
Родитель
Сommit
e294e9d9ad

+ 1 - 0
app/api/data_service/__init__.py

@@ -4,3 +4,4 @@ bp = Blueprint("data_service", __name__)
 
 # 导入 routes 模块以注册路由(副作用导入)
 from app.api.data_service import routes  # noqa: E402, F401, I001  # pyright: ignore[reportUnusedImport]
+from app.api.data_service import product_governance_routes  # noqa: E402, F401, I001  # pyright: ignore[reportUnusedImport]

+ 302 - 0
app/api/data_service/product_governance_routes.py

@@ -0,0 +1,302 @@
+"""Governed data-product, application, contract, certificate and feedback API."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.data_service import bp
+from app.core.data_service.product_governance import ProductGovernanceService
+from app.core.data_service.product_governance_repository import (
+    SqlAlchemyProductGovernanceRepository,
+    WorkCenterProductApprovalGateway,
+)
+from app.core.system.permissions import (
+    DATA_PRODUCTS_MANAGE,
+    DATA_PRODUCTS_OPERATE,
+    DATA_PRODUCTS_READ,
+    permissions_for_roles,
+    require_permissions,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    return ProductGovernanceService(
+        SqlAlchemyProductGovernanceRepository(db.session),
+        approval_gateway=WorkCenterProductApprovalGateway(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _expected_version():
+    raw = str(request.headers.get("If-Match") or "").strip()
+    if raw.startswith("W/"):
+        raw = raw[2:].strip()
+    raw = raw.strip('"')
+    if not raw.isdigit():
+        raise ValueError("missing valid If-Match version")
+    return int(raw)
+
+
+def _etag(response, version):
+    response.headers["ETag"] = f'"{int(version)}"'
+    return response
+
+
+def _error(exc):
+    db.session.rollback()
+    if "If-Match" in str(exc):
+        status = 428
+    elif isinstance(exc, LookupError):
+        status = 404
+    elif isinstance(exc, PermissionError):
+        status = 403
+    elif isinstance(exc, RuntimeError):
+        status = 409
+    else:
+        status = 400
+    return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route("/governance/products", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def list_governed_products():
+    filters = {
+        key: request.args.get(key)
+        for key in ("status", "product_type", "business_domain_uid", "owner_uid")
+        if request.args.get(key)
+    }
+    return jsonify(success(_service().list_products(**filters)))
+
+
+@bp.route("/governance/products", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_MANAGE)
+def register_governed_product():
+    try:
+        result = _service().register_product(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "数据产品治理登记已创建", code=201)), 1), 201
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def get_governed_product(product_uid):
+    try:
+        result = _service().product_detail(product_uid)
+        return _etag(jsonify(success(result)), result["current_version"])
+    except (ValueError, LookupError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/transition", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def transition_governed_product(product_uid):
+    try:
+        result = _service().transition_product(
+            product_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品生命周期已更新")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def list_product_applications():
+    permissions = permissions_for_roles(g.current_user.get("roles", []))
+    return jsonify(
+        success(
+            _service().list_applications(
+                status=request.args.get("status"),
+                requester_uid=g.current_user["id"],
+                can_manage=DATA_PRODUCTS_MANAGE in permissions,
+            )
+        )
+    )
+
+
+@bp.route("/governance/applications", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def create_product_application():
+    try:
+        result = _service().create_application(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "产品申请草稿已创建", code=201)), 1), 201
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications/<application_uid>/submit", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def submit_product_application(application_uid):
+    try:
+        result = _service().submit_application(
+            application_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品申请已进入统一审批")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications/<application_uid>/reconcile", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_MANAGE)
+def reconcile_product_application(application_uid):
+    try:
+        result = _service().reconcile_application(
+            application_uid,
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "审批结果已同步")), result["current_version"])
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications/<application_uid>/fulfill", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_MANAGE)
+def fulfill_product_application(application_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _service().fulfill_application(
+            application_uid,
+            body.get("product_uid"),
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品申请已履约")), result["current_version"])
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/contracts", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def create_product_contract(product_uid):
+    try:
+        result = _service().create_contract(
+            product_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同草稿已创建", code=201)), 1), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/compatibility", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def check_product_contract_compatibility(contract_uid):
+    try:
+        return jsonify(
+            success(
+                _service().check_contract_compatibility(
+                    contract_uid, request.get_json(silent=True) or {}
+                )
+            )
+        )
+    except (ValueError, LookupError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/revisions", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def revise_product_contract(contract_uid):
+    try:
+        result = _service().revise_contract(
+            contract_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同版本已创建")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/publish", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def publish_product_contract(contract_uid):
+    try:
+        result = _service().publish_contract(
+            contract_uid,
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同已发布")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/terminate", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def terminate_product_contract(contract_uid):
+    try:
+        result = _service().terminate_contract(
+            contract_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同已终止")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/certificates", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def issue_product_certificate(product_uid):
+    try:
+        result = _service().generate_certificate(
+            product_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "产品合格证已生成", code=201)), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/feedback", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def create_product_feedback(product_uid):
+    try:
+        result = _service().create_feedback(
+            product_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品反馈已创建", code=201)), 1), 201
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/feedback/<feedback_uid>/transition", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def transition_product_feedback(feedback_uid):
+    try:
+        result = _service().transition_feedback(
+            feedback_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "反馈改进状态已更新")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/dashboard", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def product_governance_dashboard():
+    return jsonify(success(_service().dashboard()))

+ 925 - 0
app/core/data_service/product_governance.py

@@ -0,0 +1,925 @@
+"""Governed data-product lifecycle over the legacy product and order catalog."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import re
+import uuid
+from collections.abc import Callable
+from datetime import datetime
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+
+PRODUCT_TYPES = frozenset({"database", "api", "file", "data_product"})
+PRODUCT_STATUSES = frozenset({"draft", "in_review", "active", "suspended", "retired"})
+APPLICATION_STATUSES = frozenset(
+    {"draft", "pending_approval", "approved", "rejected", "fulfilled", "cancelled"}
+)
+CONTRACT_STATUSES = frozenset({"draft", "active", "terminated"})
+COMPATIBILITY_MODES = frozenset({"backward", "full", "none"})
+FIELD_TYPES = frozenset(
+    {"string", "integer", "number", "boolean", "date", "datetime", "object", "array", "binary"}
+)
+FEEDBACK_CATEGORIES = frozenset({"quality", "freshness", "usability", "documentation", "service"})
+FEEDBACK_STATUSES = frozenset({"open", "triaged", "in_progress", "resolved", "closed"})
+CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,119}$")
+
+
+def _closed(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise ValueError(f"{label} must be an object")
+    unknown = sorted(set(value) - allowed)
+    if unknown:
+        raise ValueError(f"{label} contains unsupported fields: {', '.join(unknown)}")
+    return copy.deepcopy(value)
+
+
+def _string(value: Any, label: str, maximum: int = 1000) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    result = value.strip()
+    if len(result) > maximum:
+        raise ValueError(f"{label} exceeds {maximum} characters")
+    return result
+
+
+def _uid(value: Any, label: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (TypeError, ValueError, AttributeError) as error:
+        raise ValueError(f"{label} must be a UUID") from error
+
+
+def _list(value: Any, label: str, minimum: int = 0) -> list[Any]:
+    if not isinstance(value, list) or len(value) < minimum:
+        raise ValueError(f"{label} must contain at least {minimum} items")
+    return copy.deepcopy(value)
+
+
+def _number(value: Any, label: str, minimum: float, maximum: float) -> float:
+    try:
+        result = float(value)
+    except (TypeError, ValueError) as error:
+        raise ValueError(f"{label} must be numeric") from error
+    if result < minimum or result > maximum:
+        raise ValueError(f"{label} is outside the allowed range")
+    return result
+
+
+def _canonical_hash(value: Any) -> str:
+    encoded = json.dumps(
+        value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
+    ).encode("utf-8")
+    return hashlib.sha256(encoded).hexdigest()
+
+
+def _normalize_sla(value: Any) -> dict[str, Any]:
+    body = _closed(
+        value,
+        {"availability_target", "freshness_hours", "support_tier"},
+        "product SLA",
+    )
+    support_tier = _string(body.get("support_tier"), "support_tier", 40)
+    if support_tier not in {"best_effort", "business_hours", "24x7"}:
+        raise ValueError("unsupported support tier")
+    return {
+        "availability_target": _number(
+            body.get("availability_target"), "availability_target", 0, 100
+        ),
+        "freshness_hours": _number(
+            body.get("freshness_hours"), "freshness_hours", 0, 8760
+        ),
+        "support_tier": support_tier,
+    }
+
+
+def _normalize_schema(value: Any) -> dict[str, Any]:
+    body = _closed(value, {"fields"}, "contract schema")
+    fields = []
+    names = set()
+    for item in _list(body.get("fields"), "contract fields", minimum=1):
+        field = _closed(item, {"name", "type", "nullable"}, "contract field")
+        name = _string(field.get("name"), "field name", 200)
+        field_type = _string(field.get("type"), "field type", 30)
+        if field_type not in FIELD_TYPES:
+            raise ValueError("unsupported contract field type")
+        if name in names:
+            raise ValueError("contract field names must be unique")
+        if not isinstance(field.get("nullable"), bool):
+            raise ValueError("field nullable must be boolean")
+        names.add(name)
+        fields.append(
+            {"name": name, "type": field_type, "nullable": field["nullable"]}
+        )
+    return {"fields": fields}
+
+
+def _normalize_contract(value: Any) -> dict[str, Any]:
+    body = _closed(
+        value,
+        {
+            "schema",
+            "delivery",
+            "quality_terms",
+            "sla_terms",
+            "usage_terms",
+            "compatibility_mode",
+            "change_reason",
+        },
+        "data contract",
+    )
+    delivery = _closed(body.get("delivery"), {"mode", "format"}, "delivery")
+    mode = _string(delivery.get("mode"), "delivery mode", 40)
+    if mode not in {"table", "api", "file", "product"}:
+        raise ValueError("unsupported delivery mode")
+    quality = _closed(
+        body.get("quality_terms"), {"minimum_score"}, "quality terms"
+    )
+    sla = _closed(
+        body.get("sla_terms"),
+        {"freshness_hours", "availability_target"},
+        "SLA terms",
+    )
+    usage = _closed(
+        body.get("usage_terms"), {"purpose", "retention_days"}, "usage terms"
+    )
+    try:
+        retention_days = int(usage.get("retention_days"))
+    except (TypeError, ValueError) as error:
+        raise ValueError("retention_days must be an integer") from error
+    if retention_days < 1 or retention_days > 36500:
+        raise ValueError("retention_days is outside the allowed range")
+    compatibility_mode = _string(
+        body.get("compatibility_mode"), "compatibility_mode", 20
+    )
+    if compatibility_mode not in COMPATIBILITY_MODES:
+        raise ValueError("unsupported compatibility mode")
+    return {
+        "schema": _normalize_schema(body.get("schema")),
+        "delivery": {
+            "mode": mode,
+            "format": _string(delivery.get("format"), "delivery format", 40),
+        },
+        "quality_terms": {
+            "minimum_score": _number(
+                quality.get("minimum_score"), "minimum_score", 0, 100
+            )
+        },
+        "sla_terms": {
+            "freshness_hours": _number(
+                sla.get("freshness_hours"), "freshness_hours", 0, 8760
+            ),
+            "availability_target": _number(
+                sla.get("availability_target"), "availability_target", 0, 100
+            ),
+        },
+        "usage_terms": {
+            "purpose": _string(usage.get("purpose"), "usage purpose", 500),
+            "retention_days": retention_days,
+        },
+        "compatibility_mode": compatibility_mode,
+        "change_reason": _string(body.get("change_reason"), "change_reason", 1000),
+    }
+
+
+def _compatibility(previous: dict[str, Any], candidate: dict[str, Any]):
+    mode = candidate["compatibility_mode"]
+    if mode == "none":
+        return {"compatible": True, "breaking_changes": [], "mode": mode}
+    old_fields = {item["name"]: item for item in previous["schema"]["fields"]}
+    new_fields = {item["name"]: item for item in candidate["schema"]["fields"]}
+    changes = []
+    for name, old in old_fields.items():
+        new = new_fields.get(name)
+        if new is None:
+            changes.append(f"field {name} was removed")
+            continue
+        if new["type"] != old["type"]:
+            changes.append(f"field {name} changed type")
+        if old["nullable"] and not new["nullable"]:
+            changes.append(f"field {name} tightened nullability")
+    if mode == "full":
+        for name, field in new_fields.items():
+            if name not in old_fields and not field["nullable"]:
+                changes.append(f"required field {name} was added")
+    return {"compatible": not changes, "breaking_changes": changes, "mode": mode}
+
+
+class ProductGovernanceService:
+    """Coordinate product governance without granting data access."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        approval_gateway,
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        commit: Callable[[], None] = lambda: None,
+        rollback: Callable[[], None] = lambda: None,
+    ):
+        self.repository = repository
+        self.approval_gateway = approval_gateway
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def register_product(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {
+                "legacy_product_id",
+                "product_code",
+                "name",
+                "product_type",
+                "owner_uid",
+                "business_domain_uid",
+                "description",
+                "quality_target",
+                "sla",
+            },
+            "governed product",
+        )
+        try:
+            legacy_product_id = int(body.get("legacy_product_id"))
+        except (TypeError, ValueError) as error:
+            raise ValueError("legacy_product_id must be an integer") from error
+        if not self.repository.legacy_product_exists(legacy_product_id):
+            raise LookupError("legacy data product was not found")
+        code = _string(body.get("product_code"), "product_code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("product_code is invalid")
+        product_type = _string(body.get("product_type"), "product_type", 30)
+        if product_type not in PRODUCT_TYPES:
+            raise ValueError("unsupported product type")
+        owner = _uid(body.get("owner_uid"), "owner_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        if self.repository.users_available({owner, actor}) != {owner, actor}:
+            raise ValueError("product owner or actor is unavailable")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "legacy_product_id": legacy_product_id,
+            "product_code": code,
+            "name": _string(body.get("name"), "name", 300),
+            "product_type": product_type,
+            "owner_uid": owner,
+            "business_domain_uid": _uid(
+                body.get("business_domain_uid"), "business_domain_uid"
+            ),
+            "description": _string(body.get("description"), "description", 2000),
+            "quality_target": _number(
+                body.get("quality_target"), "quality_target", 0, 100
+            ),
+            "sla": _normalize_sla(body.get("sla")),
+            "status": "draft",
+            "current_version": 1,
+            "created_by": actor,
+            "created_at": now,
+            "updated_by": actor,
+            "updated_at": now,
+            "retired_at": None,
+        }
+        try:
+            result = self.repository.create_product(record)
+            self.repository.add_event(
+                record["uid"], "product_registered", actor, {}, version=1
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_products(self, **filters):
+        return self.repository.list_products(**filters)
+
+    def product_detail(self, uid: str):
+        result = self.repository.product_detail(_uid(uid, "product_uid"))
+        if result is None:
+            raise LookupError("governed product was not found")
+        return result
+
+    def transition_product(
+        self, uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"action", "reason"}, "product transition")
+        product_uid = _uid(uid, "product_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        product = self.repository.get_product(product_uid)
+        if product is None:
+            raise LookupError("governed product was not found")
+        if actor != product["owner_uid"]:
+            raise PermissionError("only the product owner can transition lifecycle")
+        action = _string(body.get("action"), "action", 30)
+        transitions = {
+            ("draft", "submit_review"): ("in_review", "product_submitted"),
+            ("in_review", "activate"): ("active", "product_activated"),
+            ("draft", "activate"): ("active", "product_activated"),
+            ("active", "suspend"): ("suspended", "product_suspended"),
+            ("suspended", "reactivate"): ("active", "product_reactivated"),
+            ("draft", "retire"): ("retired", "product_retired"),
+            ("in_review", "retire"): ("retired", "product_retired"),
+            ("active", "retire"): ("retired", "product_retired"),
+            ("suspended", "retire"): ("retired", "product_retired"),
+        }
+        target = transitions.get((product["status"], action))
+        if target is None:
+            raise RuntimeError("product lifecycle transition is not allowed")
+        if action in {"activate", "reactivate"}:
+            contract = self.repository.contract_for_product(product_uid)
+            certificate = self.repository.latest_certificate(product_uid)
+            if not contract or contract["status"] != "active":
+                raise RuntimeError("an active data contract is required")
+            if not certificate or certificate["status"] != "qualified":
+                raise RuntimeError("a qualified product certificate is required")
+        now = self.now_factory().isoformat()
+        product.update(
+            {
+                "status": target[0],
+                "updated_by": actor,
+                "updated_at": now,
+                "retired_at": now if target[0] == "retired" else None,
+            }
+        )
+        try:
+            result = self.repository.update_product(
+                product,
+                int(expected_version),
+                target[1],
+                actor,
+                {"reason": _string(body.get("reason"), "reason", 1000)},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def create_application(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {
+                "title",
+                "source_type",
+                "source_ref",
+                "business_domain_uid",
+                "purpose",
+                "requested_fields",
+            },
+            "product application",
+        )
+        source_type = _string(body.get("source_type"), "source_type", 30)
+        if source_type not in PRODUCT_TYPES:
+            raise ValueError("unsupported application source type")
+        source_ref = body.get("source_ref")
+        if not isinstance(source_ref, dict) or not source_ref:
+            raise ValueError("source_ref must be a non-empty object")
+        actor = _uid(actor_uid, "actor_uid")
+        if self.repository.users_available({actor}) != {actor}:
+            raise ValueError("application requester is unavailable")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "application_code": f"DPA-{self.now_factory():%Y%m%d}-{self.uid_factory()[-8:].upper()}",
+            "title": _string(body.get("title"), "title", 300),
+            "source_type": source_type,
+            "source_ref": copy.deepcopy(source_ref),
+            "business_domain_uid": _uid(
+                body.get("business_domain_uid"), "business_domain_uid"
+            ),
+            "purpose": _string(body.get("purpose"), "purpose", 1000),
+            "requested_fields": sorted(
+                {
+                    _string(item, "requested field", 200)
+                    for item in _list(
+                        body.get("requested_fields"), "requested_fields", minimum=1
+                    )
+                }
+            ),
+            "status": "draft",
+            "approval_task_uid": None,
+            "product_uid": None,
+            "current_version": 1,
+            "created_by": actor,
+            "created_at": now,
+            "updated_by": actor,
+            "updated_at": now,
+        }
+        try:
+            result = self.repository.create_application(record)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_applications(self, **filters):
+        return self.repository.list_applications(**filters)
+
+    def submit_application(
+        self, uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"workflow_uid"}, "application submission")
+        application_uid = _uid(uid, "application_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        application = self.repository.get_application(application_uid)
+        if application is None:
+            raise LookupError("product application was not found")
+        if application["status"] != "draft" or application["created_by"] != actor:
+            raise PermissionError("only the requester can submit a draft application")
+        task = self.approval_gateway.create_product_task(
+            application,
+            _uid(body.get("workflow_uid"), "workflow_uid"),
+            actor,
+        )
+        application.update(
+            {
+                "status": "pending_approval",
+                "approval_task_uid": task["uid"],
+                "updated_by": actor,
+                "updated_at": self.now_factory().isoformat(),
+            }
+        )
+        try:
+            result = self.repository.update_application(
+                application,
+                int(expected_version),
+                "application_submitted",
+                actor,
+                {"approval_task_uid": task["uid"]},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def reconcile_application(
+        self, uid: str, *, expected_version: int, actor_uid: str
+    ):
+        application_uid = _uid(uid, "application_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        application = self.repository.get_application(application_uid)
+        if application is None:
+            raise LookupError("product application was not found")
+        if application["status"] != "pending_approval":
+            raise RuntimeError("application is not waiting for approval")
+        task = self.approval_gateway.get_task(application["approval_task_uid"])
+        if not task or task["status"] not in {"approved", "rejected"}:
+            raise RuntimeError("approval task has no final decision")
+        application.update(
+            {
+                "status": task["status"],
+                "updated_by": actor,
+                "updated_at": self.now_factory().isoformat(),
+            }
+        )
+        try:
+            result = self.repository.update_application(
+                application,
+                int(expected_version),
+                f"application_{task['status']}",
+                actor,
+                {"approval_task_uid": task["uid"]},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def fulfill_application(
+        self,
+        uid: str,
+        product_uid: str,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        application_uid = _uid(uid, "application_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        application = self.repository.get_application(application_uid)
+        product = self.repository.get_product(_uid(product_uid, "product_uid"))
+        if application is None or product is None:
+            raise LookupError("application or governed product was not found")
+        if application["status"] != "approved":
+            raise RuntimeError("only an approved application can be fulfilled")
+        application.update(
+            {
+                "status": "fulfilled",
+                "product_uid": product["uid"],
+                "updated_by": actor,
+                "updated_at": self.now_factory().isoformat(),
+            }
+        )
+        try:
+            result = self.repository.update_application(
+                application,
+                int(expected_version),
+                "application_fulfilled",
+                actor,
+                {"product_uid": product["uid"], "grants_data_access": False},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def create_contract(self, product_uid: str, payload: Any, *, actor_uid: str):
+        uid = _uid(product_uid, "product_uid")
+        product = self.repository.get_product(uid)
+        if product is None:
+            raise LookupError("governed product was not found")
+        actor = _uid(actor_uid, "actor_uid")
+        if actor != product["owner_uid"]:
+            raise PermissionError("only the product owner can author a contract")
+        if self.repository.contract_for_product(uid):
+            raise RuntimeError("the product already has a data contract")
+        definition = _normalize_contract(payload)
+        now = self.now_factory().isoformat()
+        contract_uid = self.uid_factory()
+        version = {
+            "uid": self.uid_factory(),
+            "contract_uid": contract_uid,
+            "version": 1,
+            "status": "draft",
+            "definition": definition,
+            "content_hash": _canonical_hash(definition),
+            "compatibility": {"compatible": True, "breaking_changes": [], "mode": definition["compatibility_mode"]},
+            "created_by": actor,
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        contract = {
+            "uid": contract_uid,
+            "product_uid": uid,
+            "contract_code": f"DPC-{product['product_code']}",
+            "status": "draft",
+            "current_version": 1,
+            "active_version_uid": None,
+            "created_by": actor,
+            "created_at": now,
+            "updated_by": actor,
+            "updated_at": now,
+            "terminated_at": None,
+            "termination_reason": None,
+        }
+        try:
+            self.repository.create_contract(contract, version)
+            self.repository.add_event(uid, "contract_created", actor, {"contract_uid": contract_uid})
+            self.commit()
+            return {**contract, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def check_contract_compatibility(self, contract_uid: str, payload: Any):
+        uid = _uid(contract_uid, "contract_uid")
+        contract = self.repository.get_contract(uid)
+        if contract is None:
+            raise LookupError("data contract was not found")
+        candidate = _normalize_contract(payload)
+        active = self.repository.active_contract_version(uid)
+        if active is None:
+            return {"compatible": True, "breaking_changes": [], "mode": candidate["compatibility_mode"]}
+        return _compatibility(active["definition"], candidate)
+
+    def revise_contract(
+        self, contract_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        uid = _uid(contract_uid, "contract_uid")
+        contract = self.repository.get_contract(uid)
+        if contract is None:
+            raise LookupError("data contract was not found")
+        product = self.repository.get_product(contract["product_uid"])
+        actor = _uid(actor_uid, "actor_uid")
+        if actor != product["owner_uid"] or contract["status"] == "terminated":
+            raise PermissionError("contract cannot be revised by this actor")
+        definition = _normalize_contract(payload)
+        compatibility = self.check_contract_compatibility(uid, payload)
+        next_version = int(expected_version) + 1
+        now = self.now_factory().isoformat()
+        version = {
+            "uid": self.uid_factory(),
+            "contract_uid": uid,
+            "version": next_version,
+            "status": "draft",
+            "definition": definition,
+            "content_hash": _canonical_hash(definition),
+            "compatibility": compatibility,
+            "created_by": actor,
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        revised = {
+            **contract,
+            "current_version": next_version,
+            "updated_by": actor,
+            "updated_at": now,
+        }
+        try:
+            self.repository.revise_contract(revised, version, int(expected_version))
+            self.repository.add_event(
+                contract["product_uid"],
+                "contract_revised",
+                actor,
+                {"contract_uid": uid, "version": next_version, **compatibility},
+            )
+            self.commit()
+            return {**revised, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def publish_contract(
+        self, contract_uid: str, *, expected_version: int, actor_uid: str
+    ):
+        uid = _uid(contract_uid, "contract_uid")
+        contract = self.repository.get_contract(uid)
+        if contract is None:
+            raise LookupError("data contract was not found")
+        product = self.repository.get_product(contract["product_uid"])
+        actor = _uid(actor_uid, "actor_uid")
+        if actor != product["owner_uid"]:
+            raise PermissionError("only the product owner can publish a contract")
+        if int(contract["current_version"]) != int(expected_version):
+            raise RuntimeError("contract version conflict")
+        version = self.repository.contract_version(uid, int(expected_version))
+        if version is None or version["status"] != "draft":
+            raise RuntimeError("contract version is not publishable")
+        if not version["compatibility"]["compatible"]:
+            raise RuntimeError("breaking contract version cannot be published")
+        now = self.now_factory().isoformat()
+        version.update(
+            {"status": "published", "published_by": actor, "published_at": now}
+        )
+        published = {
+            **contract,
+            "status": "active",
+            "active_version_uid": version["uid"],
+            "updated_by": actor,
+            "updated_at": now,
+        }
+        try:
+            self.repository.publish_contract(published, version, int(expected_version))
+            self.repository.add_event(
+                contract["product_uid"],
+                "contract_published",
+                actor,
+                {"contract_uid": uid, "version": version["version"]},
+            )
+            self.commit()
+            return {**published, "active_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def terminate_contract(
+        self, contract_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"reason", "evidence_refs"}, "contract termination")
+        uid = _uid(contract_uid, "contract_uid")
+        contract = self.repository.get_contract(uid)
+        if contract is None:
+            raise LookupError("data contract was not found")
+        product = self.repository.get_product(contract["product_uid"])
+        actor = _uid(actor_uid, "actor_uid")
+        if actor != product["owner_uid"] or contract["status"] != "active":
+            raise PermissionError("active contract cannot be terminated by this actor")
+        evidence = [
+            _string(item, "evidence reference", 1000)
+            for item in _list(body.get("evidence_refs"), "evidence_refs", minimum=1)
+        ]
+        now = self.now_factory().isoformat()
+        terminated = {
+            **contract,
+            "status": "terminated",
+            "updated_by": actor,
+            "updated_at": now,
+            "terminated_at": now,
+            "termination_reason": _string(body.get("reason"), "reason", 1000),
+        }
+        try:
+            self.repository.terminate_contract(terminated, int(expected_version))
+            self.repository.add_event(
+                contract["product_uid"],
+                "contract_terminated",
+                actor,
+                {"reason": terminated["termination_reason"], "evidence_refs": evidence},
+            )
+            self.commit()
+            return terminated
+        except Exception:
+            self.rollback()
+            raise
+
+    def generate_certificate(self, product_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(payload, {"approval_task_uid", "evidence"}, "certificate request")
+        uid = _uid(product_uid, "product_uid")
+        product = self.repository.get_product(uid)
+        if product is None:
+            raise LookupError("governed product was not found")
+        actor = _uid(actor_uid, "actor_uid")
+        if actor != product["owner_uid"]:
+            raise PermissionError("only the product owner can issue a certificate")
+        contract = self.repository.contract_for_product(uid)
+        if not contract or contract["status"] != "active":
+            raise RuntimeError("an active data contract is required")
+        approval_uid = _uid(body.get("approval_task_uid"), "approval_task_uid")
+        approval = self.approval_gateway.get_task(approval_uid)
+        if not approval or approval["status"] != "approved":
+            raise RuntimeError("approved work-center evidence is required")
+        references = _closed(
+            body.get("evidence"),
+            {
+                "quality_run_uid",
+                "lineage_uids",
+                "rule_version_uids",
+                "workflow_run_uids",
+            },
+            "certificate evidence",
+        )
+        references = {
+            "quality_run_uid": _uid(references.get("quality_run_uid"), "quality_run_uid"),
+            "lineage_uids": [_uid(item, "lineage_uid") for item in _list(references.get("lineage_uids"), "lineage_uids", 1)],
+            "rule_version_uids": [_uid(item, "rule_version_uid") for item in _list(references.get("rule_version_uids"), "rule_version_uids", 1)],
+            "workflow_run_uids": [_uid(item, "workflow_run_uid") for item in _list(references.get("workflow_run_uids"), "workflow_run_uids", 1)],
+        }
+        snapshot = self.repository.evidence_snapshot(references)
+        quality = snapshot.get("quality") or {}
+        sla_events = snapshot.get("sla_events") or []
+        qualified = (
+            quality.get("status") == "success"
+            and float(quality.get("score", -1)) >= float(product["quality_target"])
+            and sla_events
+            and all(item.get("status") in {"met", "recovered"} for item in sla_events)
+            and snapshot.get("lineage")
+            and all(item.get("parse_status") == "resolved" for item in snapshot["lineage"])
+            and snapshot.get("rule_versions")
+            and all(item.get("status") == "published" for item in snapshot["rule_versions"])
+            and snapshot.get("workflow_runs")
+            and all(item.get("status") == "success" for item in snapshot["workflow_runs"])
+        )
+        now = self.now_factory().isoformat()
+        certificate_body = {
+            "product_uid": uid,
+            "contract_uid": contract["uid"],
+            "contract_version": contract["current_version"],
+            "approval_task_uid": approval_uid,
+            "evidence_snapshot": snapshot,
+            "status": "qualified" if qualified else "unqualified",
+        }
+        record = {
+            "uid": self.uid_factory(),
+            "certificate_code": f"DPCERT-{self.now_factory():%Y%m%d}-{self.uid_factory()[-8:].upper()}",
+            **certificate_body,
+            "content_hash": _canonical_hash(certificate_body),
+            "issued_by": actor,
+            "issued_at": now,
+        }
+        try:
+            result = self.repository.create_certificate(record)
+            self.repository.add_event(
+                uid,
+                "certificate_issued",
+                actor,
+                {"certificate_uid": record["uid"], "status": record["status"]},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def create_feedback(self, product_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"category", "rating", "summary", "details"},
+            "product feedback",
+        )
+        uid = _uid(product_uid, "product_uid")
+        if self.repository.get_product(uid) is None:
+            raise LookupError("governed product was not found")
+        actor = _uid(actor_uid, "actor_uid")
+        if self.repository.users_available({actor}) != {actor}:
+            raise ValueError("feedback author is unavailable")
+        category = _string(body.get("category"), "category", 30)
+        if category not in FEEDBACK_CATEGORIES:
+            raise ValueError("unsupported feedback category")
+        try:
+            rating = int(body.get("rating"))
+        except (TypeError, ValueError) as error:
+            raise ValueError("rating must be an integer") from error
+        if rating < 1 or rating > 5:
+            raise ValueError("rating is outside the allowed range")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "product_uid": uid,
+            "category": category,
+            "rating": rating,
+            "summary": _string(body.get("summary"), "summary", 300),
+            "details": _string(body.get("details"), "details", 2000),
+            "status": "open",
+            "assignee_uid": None,
+            "resolution": None,
+            "evidence_refs": [],
+            "current_version": 1,
+            "created_by": actor,
+            "created_at": now,
+            "updated_by": actor,
+            "updated_at": now,
+            "closed_at": None,
+        }
+        try:
+            result = self.repository.create_feedback(record)
+            self.repository.add_event(uid, "feedback_created", actor, {"feedback_uid": record["uid"]})
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def transition_feedback(
+        self, feedback_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(
+            payload,
+            {"action", "assignee_uid", "note", "evidence_refs"},
+            "feedback transition",
+        )
+        uid = _uid(feedback_uid, "feedback_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        feedback = self.repository.get_feedback(uid)
+        if feedback is None:
+            raise LookupError("product feedback was not found")
+        product = self.repository.get_product(feedback["product_uid"])
+        action = _string(body.get("action"), "action", 30)
+        transitions = {
+            ("open", "triage"): ("triaged", "feedback_triaged"),
+            ("triaged", "start"): ("in_progress", "feedback_started"),
+            ("in_progress", "resolve"): ("resolved", "feedback_resolved"),
+            ("resolved", "close"): ("closed", "feedback_closed"),
+            ("closed", "reopen"): ("open", "feedback_reopened"),
+        }
+        target = transitions.get((feedback["status"], action))
+        if target is None:
+            raise RuntimeError("feedback transition is not allowed")
+        if action in {"triage", "start", "resolve"} and actor not in {
+            product["owner_uid"],
+            feedback.get("assignee_uid"),
+        }:
+            raise PermissionError("only the owner or assignee can improve feedback")
+        if action in {"close", "reopen"} and actor not in {
+            product["owner_uid"],
+            feedback["created_by"],
+        }:
+            raise PermissionError("only the owner or author can confirm feedback")
+        note = _string(body.get("note"), "note", 2000)
+        if action == "triage":
+            assignee = _uid(body.get("assignee_uid"), "assignee_uid")
+            if self.repository.users_available({assignee}) != {assignee}:
+                raise ValueError("feedback assignee is unavailable")
+            feedback["assignee_uid"] = assignee
+        evidence = [
+            _string(item, "evidence reference", 1000)
+            for item in body.get("evidence_refs", [])
+        ]
+        if action == "resolve" and not evidence:
+            raise ValueError("feedback resolution requires evidence")
+        now = self.now_factory().isoformat()
+        feedback.update(
+            {
+                "status": target[0],
+                "resolution": note if action == "resolve" else feedback.get("resolution"),
+                "evidence_refs": evidence if action == "resolve" else feedback.get("evidence_refs", []),
+                "updated_by": actor,
+                "updated_at": now,
+                "closed_at": now if action == "close" else None,
+            }
+        )
+        try:
+            result = self.repository.update_feedback(
+                feedback,
+                int(expected_version),
+                target[1],
+                actor,
+                {"note": note, "evidence_refs": evidence},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def dashboard(self):
+        return self.repository.dashboard()

+ 817 - 0
app/core/data_service/product_governance_repository.py

@@ -0,0 +1,817 @@
+"""PostgreSQL repository and work-center adapter for product governance."""
+
+from __future__ import annotations
+
+import copy
+import json
+from datetime import datetime
+from decimal import Decimal
+from typing import Any
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.events.outbox import enqueue_outbox
+from app.core.governance.work_center import UnifiedWorkCenterService
+from app.core.governance.work_center_repository import SqlAlchemyWorkCenterRepository
+
+
+def _plain(row) -> dict[str, Any]:
+    result = dict(row)
+    for key, value in tuple(result.items()):
+        if value is None:
+            continue
+        if isinstance(value, datetime):
+            result[key] = value.isoformat()
+        elif isinstance(value, Decimal):
+            result[key] = float(value)
+        elif key.endswith("uid") or key in {
+            "uid",
+            "created_by",
+            "updated_by",
+            "issued_by",
+            "published_by",
+        }:
+            result[key] = str(value)
+    return result
+
+
+class WorkCenterProductApprovalGateway:
+    """Adapt product applications to the P2-WP07 unified task contract."""
+
+    def __init__(self, session):
+        self.repository = SqlAlchemyWorkCenterRepository(session)
+        self.service = UnifiedWorkCenterService(self.repository, rollback=session.rollback)
+
+    def create_product_task(self, application, workflow_uid, actor_uid):
+        return self.service.create_task(
+            {
+                "workflow_uid": workflow_uid,
+                "task_type": "data_product_approval",
+                "subject_type": "data_product",
+                "subject_uid": application["uid"],
+                "source_type": "data_product_application",
+                "source_uid": application["uid"],
+                "title": application["title"],
+                "description": application["purpose"],
+                "priority": "medium",
+                "business_domain_uid": application["business_domain_uid"],
+                "context": {
+                    "business_domain_uid": application["business_domain_uid"],
+                    "risk_level": "low",
+                },
+            },
+            actor_uid=actor_uid,
+        )
+
+    def get_task(self, uid):
+        return self.repository.get_task(uid)
+
+
+class SqlAlchemyProductGovernanceRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def users_available(self, user_uids):
+        values = sorted(set(user_uids))
+        if not values:
+            return set()
+        rows = self.session.execute(
+            text(
+                "SELECT id::text FROM public.users "
+                "WHERE status = 'active' AND id::text = ANY(:uids)"
+            ),
+            {"uids": values},
+        )
+        return {str(row[0]) for row in rows}
+
+    def legacy_product_exists(self, product_id):
+        return bool(
+            self.session.execute(
+                text("SELECT 1 FROM public.data_products WHERE id = :id"),
+                {"id": int(product_id)},
+            ).scalar()
+        )
+
+    def _product_select(self):
+        return """
+            SELECT uid::text AS uid, legacy_product_id, product_code, name,
+                   product_type, owner_uid::text AS owner_uid,
+                   business_domain_uid::text AS business_domain_uid,
+                   description, quality_target, sla, status, current_version,
+                   created_by::text AS created_by, created_at,
+                   updated_by::text AS updated_by, updated_at, retired_at
+            FROM public.governed_data_products
+        """
+
+    def create_product(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governed_data_products (
+                    uid, legacy_product_id, product_code, name, product_type,
+                    owner_uid, business_domain_uid, description, quality_target,
+                    sla, status, current_version, created_by, created_at,
+                    updated_by, updated_at, retired_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :legacy_product_id, :product_code, :name,
+                    :product_type, CAST(:owner_uid AS uuid),
+                    CAST(:business_domain_uid AS uuid), :description,
+                    :quality_target, CAST(:sla AS jsonb), :status,
+                    :current_version, CAST(:created_by AS uuid), :created_at,
+                    CAST(:updated_by AS uuid), :updated_at, :retired_at
+                )
+                """
+            ),
+            {**record, "sla": json.dumps(record["sla"], ensure_ascii=False)},
+        )
+        return copy.deepcopy(record)
+
+    def get_product(self, uid):
+        row = (
+            self.session.execute(
+                text(self._product_select() + " WHERE uid = CAST(:uid AS uuid)"),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def list_products(self, **filters):
+        clauses = []
+        params = {}
+        for key in ("status", "product_type", "business_domain_uid", "owner_uid"):
+            value = filters.get(key)
+            if not value:
+                continue
+            if key.endswith("uid"):
+                clauses.append(f"{key} = CAST(:{key} AS uuid)")
+            else:
+                clauses.append(f"{key} = :{key}")
+            params[key] = value
+        where = " WHERE " + " AND ".join(clauses) if clauses else ""
+        rows = self.session.execute(
+            text(self._product_select() + where + " ORDER BY updated_at DESC, uid DESC"),
+            params,
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def update_product(self, record, expected_version, action, actor_uid, payload):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.governed_data_products SET
+                    status = :status, current_version = current_version + 1,
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at,
+                    retired_at = :retired_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**record, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("product version conflict")
+        saved = self.get_product(record["uid"])
+        self.add_event(
+            saved["uid"], action, actor_uid, payload, saved["current_version"]
+        )
+        return saved
+
+    def add_event(self, product_uid, action, actor_uid, payload, version=1):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_governance_events (
+                    uid, product_uid, product_version, action,
+                    actor_uid, payload, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:product_uid AS uuid), :version,
+                    :action, CAST(:actor_uid AS uuid), CAST(:payload AS jsonb),
+                    CURRENT_TIMESTAMP
+                )
+                """
+            ),
+            {
+                "uid": new_governance_uid(),
+                "product_uid": product_uid,
+                "version": max(1, int(version)),
+                "action": action,
+                "actor_uid": actor_uid,
+                "payload": json.dumps(payload, ensure_ascii=False),
+            },
+        )
+        event_type = {
+            "contract_published": "data_product.contract.published",
+            "contract_terminated": "data_product.contract.terminated",
+            "certificate_issued": "data_product.certificate.issued",
+            "feedback_resolved": "data_product.feedback.resolved",
+        }.get(action, "data_product.governance.evidence.recorded")
+        enqueue_outbox(
+            self.session,
+            aggregate_type="governed_data_product",
+            aggregate_id=product_uid,
+            event_type=event_type,
+            payload={
+                "product_uid": product_uid,
+                "action": action,
+                "evidence": payload,
+                "grants_data_access": False,
+            },
+        )
+
+    def _application_select(self):
+        return """
+            SELECT uid::text AS uid, application_code, title, source_type,
+                   source_ref, business_domain_uid::text AS business_domain_uid,
+                   purpose, requested_fields, status,
+                   approval_task_uid::text AS approval_task_uid,
+                   product_uid::text AS product_uid, current_version,
+                   created_by::text AS created_by, created_at,
+                   updated_by::text AS updated_by, updated_at
+            FROM public.data_product_applications
+        """
+
+    def create_application(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_applications (
+                    uid, application_code, title, source_type, source_ref,
+                    business_domain_uid, purpose, requested_fields, status,
+                    approval_task_uid, product_uid, current_version,
+                    created_by, created_at, updated_by, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :application_code, :title, :source_type,
+                    CAST(:source_ref AS jsonb), CAST(:business_domain_uid AS uuid),
+                    :purpose, CAST(:requested_fields AS jsonb), :status,
+                    CAST(:approval_task_uid AS uuid), CAST(:product_uid AS uuid),
+                    :current_version, CAST(:created_by AS uuid), :created_at,
+                    CAST(:updated_by AS uuid), :updated_at
+                )
+                """
+            ),
+            {
+                **record,
+                "source_ref": json.dumps(record["source_ref"], ensure_ascii=False),
+                "requested_fields": json.dumps(record["requested_fields"], ensure_ascii=False),
+            },
+        )
+        self._application_event(record, "application_created", record["created_by"], {})
+        return copy.deepcopy(record)
+
+    def get_application(self, uid):
+        row = (
+            self.session.execute(
+                text(self._application_select() + " WHERE uid = CAST(:uid AS uuid)"),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def list_applications(self, **filters):
+        clauses = []
+        params = {}
+        if filters.get("status"):
+            clauses.append("status = :status")
+            params["status"] = filters["status"]
+        requester_uid = filters.get("requester_uid")
+        if requester_uid and not filters.get("can_manage"):
+            clauses.append("created_by = CAST(:requester_uid AS uuid)")
+            params["requester_uid"] = requester_uid
+        where = " WHERE " + " AND ".join(clauses) if clauses else ""
+        rows = self.session.execute(
+            text(self._application_select() + where + " ORDER BY updated_at DESC, uid DESC"),
+            params,
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def update_application(self, record, expected_version, action, actor_uid, payload):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_applications SET
+                    status = :status,
+                    approval_task_uid = CAST(:approval_task_uid AS uuid),
+                    product_uid = CAST(:product_uid AS uuid),
+                    current_version = current_version + 1,
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**record, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("application version conflict")
+        saved = self.get_application(record["uid"])
+        self._application_event(saved, action, actor_uid, payload)
+        if saved.get("product_uid"):
+            self.add_event(saved["product_uid"], action, actor_uid, payload)
+        return saved
+
+    def _application_event(self, application, action, actor_uid, payload):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_application_events (
+                    uid, application_uid, application_version, action,
+                    actor_uid, payload, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:application_uid AS uuid), :version,
+                    :action, CAST(:actor_uid AS uuid), CAST(:payload AS jsonb),
+                    CURRENT_TIMESTAMP
+                )
+                """
+            ),
+            {
+                "uid": new_governance_uid(),
+                "application_uid": application["uid"],
+                "version": application["current_version"],
+                "action": action,
+                "actor_uid": actor_uid,
+                "payload": json.dumps(payload, ensure_ascii=False),
+            },
+        )
+
+    def _contract_select(self):
+        return """
+            SELECT uid::text AS uid, product_uid::text AS product_uid,
+                   contract_code, status, current_version,
+                   active_version_uid::text AS active_version_uid,
+                   created_by::text AS created_by, created_at,
+                   updated_by::text AS updated_by, updated_at,
+                   terminated_at, termination_reason
+            FROM public.data_product_contracts
+        """
+
+    def create_contract(self, contract, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_contracts (
+                    uid, product_uid, contract_code, status, current_version,
+                    active_version_uid, created_by, created_at, updated_by,
+                    updated_at, terminated_at, termination_reason
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:product_uid AS uuid), :contract_code,
+                    :status, :current_version, NULL, CAST(:created_by AS uuid),
+                    :created_at, CAST(:updated_by AS uuid), :updated_at,
+                    :terminated_at, :termination_reason
+                )
+                """
+            ),
+            contract,
+        )
+        self._insert_contract_version(version)
+        return copy.deepcopy(contract)
+
+    def _insert_contract_version(self, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_contract_versions (
+                    uid, contract_uid, version, status, definition,
+                    content_hash, compatibility, created_by, created_at,
+                    published_by, published_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:contract_uid AS uuid), :version,
+                    :status, CAST(:definition AS jsonb), :content_hash,
+                    CAST(:compatibility AS jsonb), CAST(:created_by AS uuid),
+                    :created_at, CAST(:published_by AS uuid), :published_at
+                )
+                """
+            ),
+            {
+                **version,
+                "definition": json.dumps(version["definition"], ensure_ascii=False),
+                "compatibility": json.dumps(version["compatibility"], ensure_ascii=False),
+            },
+        )
+
+    def get_contract(self, uid):
+        row = (
+            self.session.execute(
+                text(self._contract_select() + " WHERE uid = CAST(:uid AS uuid)"),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def contract_for_product(self, product_uid):
+        row = (
+            self.session.execute(
+                text(self._contract_select() + " WHERE product_uid = CAST(:uid AS uuid)"),
+                {"uid": product_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def contract_version(self, contract_uid, version):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, contract_uid::text AS contract_uid,
+                           version, status, definition, content_hash, compatibility,
+                           created_by::text AS created_by, created_at,
+                           published_by::text AS published_by, published_at
+                    FROM public.data_product_contract_versions
+                    WHERE contract_uid = CAST(:uid AS uuid) AND version = :version
+                    """
+                ),
+                {"uid": contract_uid, "version": version},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def active_contract_version(self, contract_uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT v.uid::text AS uid, v.contract_uid::text AS contract_uid,
+                           v.version, v.status, v.definition, v.content_hash,
+                           v.compatibility, v.created_by::text AS created_by,
+                           v.created_at, v.published_by::text AS published_by,
+                           v.published_at
+                    FROM public.data_product_contracts c
+                    JOIN public.data_product_contract_versions v
+                      ON v.uid = c.active_version_uid
+                    WHERE c.uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": contract_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def revise_contract(self, contract, version, expected_version):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_contracts SET
+                    current_version = :current_version,
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**contract, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("contract version conflict")
+        self._insert_contract_version(version)
+        return self.get_contract(contract["uid"])
+
+    def publish_contract(self, contract, version, expected_version):
+        if int(contract["current_version"]) != int(expected_version):
+            raise RuntimeError("contract version conflict")
+        self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_contract_versions SET status = 'superseded'
+                WHERE contract_uid = CAST(:uid AS uuid) AND status = 'published'
+                """
+            ),
+            {"uid": contract["uid"]},
+        )
+        changed_version = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_contract_versions SET
+                    status = 'published', published_by = CAST(:published_by AS uuid),
+                    published_at = :published_at
+                WHERE uid = CAST(:uid AS uuid) AND status = 'draft'
+                """
+            ),
+            version,
+        )
+        changed_contract = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_contracts SET
+                    status = 'active', active_version_uid = CAST(:active_version_uid AS uuid),
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**contract, "expected_version": expected_version},
+        )
+        if changed_version.rowcount != 1 or changed_contract.rowcount != 1:
+            raise RuntimeError("contract version conflict")
+        return self.get_contract(contract["uid"])
+
+    def terminate_contract(self, contract, expected_version):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_contracts SET
+                    status = 'terminated', updated_by = CAST(:updated_by AS uuid),
+                    updated_at = :updated_at, terminated_at = :terminated_at,
+                    termination_reason = :termination_reason
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                  AND status = 'active'
+                """
+            ),
+            {**contract, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("contract version conflict")
+        return self.get_contract(contract["uid"])
+
+    def evidence_snapshot(self, references):
+        quality = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, status, score, batch_key,
+                           template_version_uid::text AS template_version_uid,
+                           asset_uid::text AS asset_uid, source_uid::text AS source_uid,
+                           created_at
+                    FROM public.quality_profile_runs
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": references["quality_run_uid"]},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if quality is None:
+            raise LookupError("quality run evidence was not found")
+        sla_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, sla_type, status, severity,
+                       actual, threshold, escalation_level, created_at
+                FROM public.quality_sla_events
+                WHERE run_uid = CAST(:uid AS uuid) ORDER BY sla_type
+                """
+            ),
+            {"uid": references["quality_run_uid"]},
+        ).mappings()
+        lineage_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, parse_status, source_asset,
+                       source_field, target_asset, target_field,
+                       relation_type, failure_reason, created_at
+                FROM public.active_metadata_lineage
+                WHERE uid::text = ANY(:uids) ORDER BY uid
+                """
+            ),
+            {"uids": references["lineage_uids"]},
+        ).mappings()
+        rule_rows = self.session.execute(
+            text(
+                """
+                SELECT id::text AS uid, rule_uid::text AS rule_uid,
+                       version_no AS version, spec_hash, status, published_at
+                FROM public.data_rule_versions
+                WHERE id::text = ANY(:uids) ORDER BY id
+                """
+            ),
+            {"uids": references["rule_version_uids"]},
+        ).mappings()
+        workflow_rows = self.session.execute(
+            text(
+                """
+                SELECT id::text AS uid, workflow_version_id::text AS workflow_version_uid,
+                       engine_type, trigger_type, status, started_at, finished_at, created_at
+                FROM public.workflow_runs
+                WHERE id::text = ANY(:uids) ORDER BY id
+                """
+            ),
+            {"uids": references["workflow_run_uids"]},
+        ).mappings()
+        lineage = [_plain(row) for row in lineage_rows]
+        rules = [_plain(row) for row in rule_rows]
+        workflows = [_plain(row) for row in workflow_rows]
+        if len(lineage) != len(set(references["lineage_uids"])):
+            raise LookupError("lineage evidence was not found")
+        if len(rules) != len(set(references["rule_version_uids"])):
+            raise LookupError("rule version evidence was not found")
+        if len(workflows) != len(set(references["workflow_run_uids"])):
+            raise LookupError("workflow run evidence was not found")
+        return {
+            "quality": _plain(quality),
+            "sla_events": [_plain(row) for row in sla_rows],
+            "lineage": lineage,
+            "rule_versions": rules,
+            "workflow_runs": workflows,
+        }
+
+    def create_certificate(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_certificates (
+                    uid, certificate_code, product_uid, contract_uid,
+                    contract_version, approval_task_uid, evidence_snapshot,
+                    status, content_hash, issued_by, issued_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :certificate_code,
+                    CAST(:product_uid AS uuid), CAST(:contract_uid AS uuid),
+                    :contract_version, CAST(:approval_task_uid AS uuid),
+                    CAST(:evidence_snapshot AS jsonb), :status, :content_hash,
+                    CAST(:issued_by AS uuid), :issued_at
+                )
+                """
+            ),
+            {
+                **record,
+                "evidence_snapshot": json.dumps(record["evidence_snapshot"], ensure_ascii=False),
+            },
+        )
+        return copy.deepcopy(record)
+
+    def latest_certificate(self, product_uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, certificate_code,
+                           product_uid::text AS product_uid,
+                           contract_uid::text AS contract_uid, contract_version,
+                           approval_task_uid::text AS approval_task_uid,
+                           evidence_snapshot, status, content_hash,
+                           issued_by::text AS issued_by, issued_at
+                    FROM public.data_product_certificates
+                    WHERE product_uid = CAST(:uid AS uuid)
+                    ORDER BY issued_at DESC, uid DESC LIMIT 1
+                    """
+                ),
+                {"uid": product_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def create_feedback(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_feedback (
+                    uid, product_uid, category, rating, summary, details,
+                    status, assignee_uid, resolution, evidence_refs,
+                    current_version, created_by, created_at,
+                    updated_by, updated_at, closed_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:product_uid AS uuid), :category,
+                    :rating, :summary, :details, :status,
+                    CAST(:assignee_uid AS uuid), :resolution,
+                    CAST(:evidence_refs AS jsonb), :current_version,
+                    CAST(:created_by AS uuid), :created_at,
+                    CAST(:updated_by AS uuid), :updated_at, :closed_at
+                )
+                """
+            ),
+            {**record, "evidence_refs": json.dumps(record["evidence_refs"])},
+        )
+        return copy.deepcopy(record)
+
+    def get_feedback(self, uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, product_uid::text AS product_uid,
+                           category, rating, summary, details, status,
+                           assignee_uid::text AS assignee_uid, resolution,
+                           evidence_refs, current_version,
+                           created_by::text AS created_by, created_at,
+                           updated_by::text AS updated_by, updated_at, closed_at
+                    FROM public.data_product_feedback
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def update_feedback(self, record, expected_version, action, actor_uid, payload):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_feedback SET
+                    status = :status, assignee_uid = CAST(:assignee_uid AS uuid),
+                    resolution = :resolution, evidence_refs = CAST(:evidence_refs AS jsonb),
+                    current_version = current_version + 1,
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at,
+                    closed_at = :closed_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {
+                **record,
+                "evidence_refs": json.dumps(record["evidence_refs"]),
+                "expected_version": expected_version,
+            },
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("feedback version conflict")
+        saved = self.get_feedback(record["uid"])
+        self.add_event(saved["product_uid"], action, actor_uid, payload)
+        return saved
+
+    def product_detail(self, uid):
+        product = self.get_product(uid)
+        if product is None:
+            return None
+        contract = self.contract_for_product(uid)
+        versions = []
+        if contract:
+            rows = self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, contract_uid::text AS contract_uid,
+                           version, status, definition, content_hash, compatibility,
+                           created_by::text AS created_by, created_at,
+                           published_by::text AS published_by, published_at
+                    FROM public.data_product_contract_versions
+                    WHERE contract_uid = CAST(:uid AS uuid)
+                    ORDER BY version DESC
+                    """
+                ),
+                {"uid": contract["uid"]},
+            ).mappings()
+            versions = [_plain(row) for row in rows]
+        certificate_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, certificate_code,
+                       product_uid::text AS product_uid,
+                       contract_uid::text AS contract_uid, contract_version,
+                       approval_task_uid::text AS approval_task_uid,
+                       evidence_snapshot, status, content_hash,
+                       issued_by::text AS issued_by, issued_at
+                FROM public.data_product_certificates
+                WHERE product_uid = CAST(:uid AS uuid)
+                ORDER BY issued_at DESC
+                """
+            ),
+            {"uid": uid},
+        ).mappings()
+        feedback_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, product_uid::text AS product_uid,
+                       category, rating, summary, details, status,
+                       assignee_uid::text AS assignee_uid, resolution,
+                       evidence_refs, current_version,
+                       created_by::text AS created_by, created_at,
+                       updated_by::text AS updated_by, updated_at, closed_at
+                FROM public.data_product_feedback
+                WHERE product_uid = CAST(:uid AS uuid)
+                ORDER BY updated_at DESC
+                """
+            ),
+            {"uid": uid},
+        ).mappings()
+        event_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, product_uid::text AS product_uid,
+                       product_version, action, actor_uid::text AS actor_uid,
+                       payload, created_at
+                FROM public.data_product_governance_events
+                WHERE product_uid = CAST(:uid AS uuid)
+                ORDER BY created_at, uid
+                """
+            ),
+            {"uid": uid},
+        ).mappings()
+        return {
+            **product,
+            "contract": {**contract, "versions": versions} if contract else None,
+            "certificates": [_plain(row) for row in certificate_rows],
+            "feedback": [_plain(row) for row in feedback_rows],
+            "timeline": [_plain(row) for row in event_rows],
+        }
+
+    def dashboard(self):
+        row = self.session.execute(
+            text(
+                """
+                SELECT
+                    (SELECT COUNT(*) FROM public.governed_data_products) AS product_count,
+                    (SELECT COUNT(*) FROM public.governed_data_products WHERE status = 'active') AS active_count,
+                    (SELECT COUNT(*) FROM public.data_product_applications WHERE status = 'pending_approval') AS application_pending_count,
+                    (SELECT COUNT(*) FROM public.data_product_feedback WHERE status <> 'closed') AS open_feedback_count
+                """
+            )
+        ).mappings().one()
+        return {key: int(value or 0) for key, value in row.items()}

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

@@ -61,6 +61,9 @@ DATA_OBSERVABILITY_MANAGE = "data-observability:manage"
 WORK_CENTER_READ = "governance:work-center:read"
 WORK_CENTER_OPERATE = "governance:work-center:operate"
 WORK_CENTER_MANAGE = "governance:work-center:manage"
+DATA_PRODUCTS_READ = "data-products:read"
+DATA_PRODUCTS_OPERATE = "data-products:operate"
+DATA_PRODUCTS_MANAGE = "data-products:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -72,6 +75,7 @@ ROLE_PERMISSIONS = {
             ACTIVE_METADATA_READ,
             DATA_OBSERVABILITY_READ,
             WORK_CENTER_READ,
+            DATA_PRODUCTS_READ,
         }
     ),
     "editor": frozenset(
@@ -103,6 +107,8 @@ ROLE_PERMISSIONS = {
             DATA_OBSERVABILITY_OPERATE,
             WORK_CENTER_READ,
             WORK_CENTER_OPERATE,
+            DATA_PRODUCTS_READ,
+            DATA_PRODUCTS_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -161,6 +167,9 @@ ROLE_PERMISSIONS = {
             WORK_CENTER_READ,
             WORK_CENTER_OPERATE,
             WORK_CENTER_MANAGE,
+            DATA_PRODUCTS_READ,
+            DATA_PRODUCTS_OPERATE,
+            DATA_PRODUCTS_MANAGE,
         }
     ),
 }
@@ -190,6 +199,18 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if method == "GET":
             return (GOVERNANCE_AUDIT_READ,)
         return (GOVERNANCE_AUDIT_SEAL,)
+    if path.startswith("/api/dataservice/governance"):
+        if method == "GET":
+            return (DATA_PRODUCTS_READ,)
+        if any(
+            marker in path
+            for marker in (
+                "/reconcile",
+                "/fulfill",
+            )
+        ) or path == "/api/dataservice/governance/products":
+            return (DATA_PRODUCTS_MANAGE,)
+        return (DATA_PRODUCTS_OPERATE,)
     if path.startswith("/api/meta/domain-templates"):
         if method == "GET":
             return (DOMAIN_TEMPLATES_READ,)

+ 1 - 0
deployment/app/api/data_service/__init__.py

@@ -4,3 +4,4 @@ bp = Blueprint("data_service", __name__)
 
 # 导入 routes 模块以注册路由(副作用导入)
 from app.api.data_service import routes  # noqa: E402, F401, I001  # pyright: ignore[reportUnusedImport]
+from app.api.data_service import product_governance_routes  # noqa: E402, F401, I001  # pyright: ignore[reportUnusedImport]

+ 302 - 0
deployment/app/api/data_service/product_governance_routes.py

@@ -0,0 +1,302 @@
+"""Governed data-product, application, contract, certificate and feedback API."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.data_service import bp
+from app.core.data_service.product_governance import ProductGovernanceService
+from app.core.data_service.product_governance_repository import (
+    SqlAlchemyProductGovernanceRepository,
+    WorkCenterProductApprovalGateway,
+)
+from app.core.system.permissions import (
+    DATA_PRODUCTS_MANAGE,
+    DATA_PRODUCTS_OPERATE,
+    DATA_PRODUCTS_READ,
+    permissions_for_roles,
+    require_permissions,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    return ProductGovernanceService(
+        SqlAlchemyProductGovernanceRepository(db.session),
+        approval_gateway=WorkCenterProductApprovalGateway(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _expected_version():
+    raw = str(request.headers.get("If-Match") or "").strip()
+    if raw.startswith("W/"):
+        raw = raw[2:].strip()
+    raw = raw.strip('"')
+    if not raw.isdigit():
+        raise ValueError("missing valid If-Match version")
+    return int(raw)
+
+
+def _etag(response, version):
+    response.headers["ETag"] = f'"{int(version)}"'
+    return response
+
+
+def _error(exc):
+    db.session.rollback()
+    if "If-Match" in str(exc):
+        status = 428
+    elif isinstance(exc, LookupError):
+        status = 404
+    elif isinstance(exc, PermissionError):
+        status = 403
+    elif isinstance(exc, RuntimeError):
+        status = 409
+    else:
+        status = 400
+    return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route("/governance/products", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def list_governed_products():
+    filters = {
+        key: request.args.get(key)
+        for key in ("status", "product_type", "business_domain_uid", "owner_uid")
+        if request.args.get(key)
+    }
+    return jsonify(success(_service().list_products(**filters)))
+
+
+@bp.route("/governance/products", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_MANAGE)
+def register_governed_product():
+    try:
+        result = _service().register_product(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "数据产品治理登记已创建", code=201)), 1), 201
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def get_governed_product(product_uid):
+    try:
+        result = _service().product_detail(product_uid)
+        return _etag(jsonify(success(result)), result["current_version"])
+    except (ValueError, LookupError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/transition", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def transition_governed_product(product_uid):
+    try:
+        result = _service().transition_product(
+            product_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品生命周期已更新")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def list_product_applications():
+    permissions = permissions_for_roles(g.current_user.get("roles", []))
+    return jsonify(
+        success(
+            _service().list_applications(
+                status=request.args.get("status"),
+                requester_uid=g.current_user["id"],
+                can_manage=DATA_PRODUCTS_MANAGE in permissions,
+            )
+        )
+    )
+
+
+@bp.route("/governance/applications", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def create_product_application():
+    try:
+        result = _service().create_application(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "产品申请草稿已创建", code=201)), 1), 201
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications/<application_uid>/submit", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def submit_product_application(application_uid):
+    try:
+        result = _service().submit_application(
+            application_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品申请已进入统一审批")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications/<application_uid>/reconcile", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_MANAGE)
+def reconcile_product_application(application_uid):
+    try:
+        result = _service().reconcile_application(
+            application_uid,
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "审批结果已同步")), result["current_version"])
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications/<application_uid>/fulfill", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_MANAGE)
+def fulfill_product_application(application_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _service().fulfill_application(
+            application_uid,
+            body.get("product_uid"),
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品申请已履约")), result["current_version"])
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/contracts", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def create_product_contract(product_uid):
+    try:
+        result = _service().create_contract(
+            product_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同草稿已创建", code=201)), 1), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/compatibility", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def check_product_contract_compatibility(contract_uid):
+    try:
+        return jsonify(
+            success(
+                _service().check_contract_compatibility(
+                    contract_uid, request.get_json(silent=True) or {}
+                )
+            )
+        )
+    except (ValueError, LookupError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/revisions", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def revise_product_contract(contract_uid):
+    try:
+        result = _service().revise_contract(
+            contract_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同版本已创建")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/publish", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def publish_product_contract(contract_uid):
+    try:
+        result = _service().publish_contract(
+            contract_uid,
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同已发布")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/terminate", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def terminate_product_contract(contract_uid):
+    try:
+        result = _service().terminate_contract(
+            contract_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同已终止")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/certificates", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def issue_product_certificate(product_uid):
+    try:
+        result = _service().generate_certificate(
+            product_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "产品合格证已生成", code=201)), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/feedback", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def create_product_feedback(product_uid):
+    try:
+        result = _service().create_feedback(
+            product_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品反馈已创建", code=201)), 1), 201
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/feedback/<feedback_uid>/transition", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def transition_product_feedback(feedback_uid):
+    try:
+        result = _service().transition_feedback(
+            feedback_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "反馈改进状态已更新")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/dashboard", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def product_governance_dashboard():
+    return jsonify(success(_service().dashboard()))

+ 925 - 0
deployment/app/core/data_service/product_governance.py

@@ -0,0 +1,925 @@
+"""Governed data-product lifecycle over the legacy product and order catalog."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import re
+import uuid
+from collections.abc import Callable
+from datetime import datetime
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+
+PRODUCT_TYPES = frozenset({"database", "api", "file", "data_product"})
+PRODUCT_STATUSES = frozenset({"draft", "in_review", "active", "suspended", "retired"})
+APPLICATION_STATUSES = frozenset(
+    {"draft", "pending_approval", "approved", "rejected", "fulfilled", "cancelled"}
+)
+CONTRACT_STATUSES = frozenset({"draft", "active", "terminated"})
+COMPATIBILITY_MODES = frozenset({"backward", "full", "none"})
+FIELD_TYPES = frozenset(
+    {"string", "integer", "number", "boolean", "date", "datetime", "object", "array", "binary"}
+)
+FEEDBACK_CATEGORIES = frozenset({"quality", "freshness", "usability", "documentation", "service"})
+FEEDBACK_STATUSES = frozenset({"open", "triaged", "in_progress", "resolved", "closed"})
+CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,119}$")
+
+
+def _closed(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise ValueError(f"{label} must be an object")
+    unknown = sorted(set(value) - allowed)
+    if unknown:
+        raise ValueError(f"{label} contains unsupported fields: {', '.join(unknown)}")
+    return copy.deepcopy(value)
+
+
+def _string(value: Any, label: str, maximum: int = 1000) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    result = value.strip()
+    if len(result) > maximum:
+        raise ValueError(f"{label} exceeds {maximum} characters")
+    return result
+
+
+def _uid(value: Any, label: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (TypeError, ValueError, AttributeError) as error:
+        raise ValueError(f"{label} must be a UUID") from error
+
+
+def _list(value: Any, label: str, minimum: int = 0) -> list[Any]:
+    if not isinstance(value, list) or len(value) < minimum:
+        raise ValueError(f"{label} must contain at least {minimum} items")
+    return copy.deepcopy(value)
+
+
+def _number(value: Any, label: str, minimum: float, maximum: float) -> float:
+    try:
+        result = float(value)
+    except (TypeError, ValueError) as error:
+        raise ValueError(f"{label} must be numeric") from error
+    if result < minimum or result > maximum:
+        raise ValueError(f"{label} is outside the allowed range")
+    return result
+
+
+def _canonical_hash(value: Any) -> str:
+    encoded = json.dumps(
+        value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
+    ).encode("utf-8")
+    return hashlib.sha256(encoded).hexdigest()
+
+
+def _normalize_sla(value: Any) -> dict[str, Any]:
+    body = _closed(
+        value,
+        {"availability_target", "freshness_hours", "support_tier"},
+        "product SLA",
+    )
+    support_tier = _string(body.get("support_tier"), "support_tier", 40)
+    if support_tier not in {"best_effort", "business_hours", "24x7"}:
+        raise ValueError("unsupported support tier")
+    return {
+        "availability_target": _number(
+            body.get("availability_target"), "availability_target", 0, 100
+        ),
+        "freshness_hours": _number(
+            body.get("freshness_hours"), "freshness_hours", 0, 8760
+        ),
+        "support_tier": support_tier,
+    }
+
+
+def _normalize_schema(value: Any) -> dict[str, Any]:
+    body = _closed(value, {"fields"}, "contract schema")
+    fields = []
+    names = set()
+    for item in _list(body.get("fields"), "contract fields", minimum=1):
+        field = _closed(item, {"name", "type", "nullable"}, "contract field")
+        name = _string(field.get("name"), "field name", 200)
+        field_type = _string(field.get("type"), "field type", 30)
+        if field_type not in FIELD_TYPES:
+            raise ValueError("unsupported contract field type")
+        if name in names:
+            raise ValueError("contract field names must be unique")
+        if not isinstance(field.get("nullable"), bool):
+            raise ValueError("field nullable must be boolean")
+        names.add(name)
+        fields.append(
+            {"name": name, "type": field_type, "nullable": field["nullable"]}
+        )
+    return {"fields": fields}
+
+
+def _normalize_contract(value: Any) -> dict[str, Any]:
+    body = _closed(
+        value,
+        {
+            "schema",
+            "delivery",
+            "quality_terms",
+            "sla_terms",
+            "usage_terms",
+            "compatibility_mode",
+            "change_reason",
+        },
+        "data contract",
+    )
+    delivery = _closed(body.get("delivery"), {"mode", "format"}, "delivery")
+    mode = _string(delivery.get("mode"), "delivery mode", 40)
+    if mode not in {"table", "api", "file", "product"}:
+        raise ValueError("unsupported delivery mode")
+    quality = _closed(
+        body.get("quality_terms"), {"minimum_score"}, "quality terms"
+    )
+    sla = _closed(
+        body.get("sla_terms"),
+        {"freshness_hours", "availability_target"},
+        "SLA terms",
+    )
+    usage = _closed(
+        body.get("usage_terms"), {"purpose", "retention_days"}, "usage terms"
+    )
+    try:
+        retention_days = int(usage.get("retention_days"))
+    except (TypeError, ValueError) as error:
+        raise ValueError("retention_days must be an integer") from error
+    if retention_days < 1 or retention_days > 36500:
+        raise ValueError("retention_days is outside the allowed range")
+    compatibility_mode = _string(
+        body.get("compatibility_mode"), "compatibility_mode", 20
+    )
+    if compatibility_mode not in COMPATIBILITY_MODES:
+        raise ValueError("unsupported compatibility mode")
+    return {
+        "schema": _normalize_schema(body.get("schema")),
+        "delivery": {
+            "mode": mode,
+            "format": _string(delivery.get("format"), "delivery format", 40),
+        },
+        "quality_terms": {
+            "minimum_score": _number(
+                quality.get("minimum_score"), "minimum_score", 0, 100
+            )
+        },
+        "sla_terms": {
+            "freshness_hours": _number(
+                sla.get("freshness_hours"), "freshness_hours", 0, 8760
+            ),
+            "availability_target": _number(
+                sla.get("availability_target"), "availability_target", 0, 100
+            ),
+        },
+        "usage_terms": {
+            "purpose": _string(usage.get("purpose"), "usage purpose", 500),
+            "retention_days": retention_days,
+        },
+        "compatibility_mode": compatibility_mode,
+        "change_reason": _string(body.get("change_reason"), "change_reason", 1000),
+    }
+
+
+def _compatibility(previous: dict[str, Any], candidate: dict[str, Any]):
+    mode = candidate["compatibility_mode"]
+    if mode == "none":
+        return {"compatible": True, "breaking_changes": [], "mode": mode}
+    old_fields = {item["name"]: item for item in previous["schema"]["fields"]}
+    new_fields = {item["name"]: item for item in candidate["schema"]["fields"]}
+    changes = []
+    for name, old in old_fields.items():
+        new = new_fields.get(name)
+        if new is None:
+            changes.append(f"field {name} was removed")
+            continue
+        if new["type"] != old["type"]:
+            changes.append(f"field {name} changed type")
+        if old["nullable"] and not new["nullable"]:
+            changes.append(f"field {name} tightened nullability")
+    if mode == "full":
+        for name, field in new_fields.items():
+            if name not in old_fields and not field["nullable"]:
+                changes.append(f"required field {name} was added")
+    return {"compatible": not changes, "breaking_changes": changes, "mode": mode}
+
+
+class ProductGovernanceService:
+    """Coordinate product governance without granting data access."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        approval_gateway,
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        commit: Callable[[], None] = lambda: None,
+        rollback: Callable[[], None] = lambda: None,
+    ):
+        self.repository = repository
+        self.approval_gateway = approval_gateway
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def register_product(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {
+                "legacy_product_id",
+                "product_code",
+                "name",
+                "product_type",
+                "owner_uid",
+                "business_domain_uid",
+                "description",
+                "quality_target",
+                "sla",
+            },
+            "governed product",
+        )
+        try:
+            legacy_product_id = int(body.get("legacy_product_id"))
+        except (TypeError, ValueError) as error:
+            raise ValueError("legacy_product_id must be an integer") from error
+        if not self.repository.legacy_product_exists(legacy_product_id):
+            raise LookupError("legacy data product was not found")
+        code = _string(body.get("product_code"), "product_code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("product_code is invalid")
+        product_type = _string(body.get("product_type"), "product_type", 30)
+        if product_type not in PRODUCT_TYPES:
+            raise ValueError("unsupported product type")
+        owner = _uid(body.get("owner_uid"), "owner_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        if self.repository.users_available({owner, actor}) != {owner, actor}:
+            raise ValueError("product owner or actor is unavailable")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "legacy_product_id": legacy_product_id,
+            "product_code": code,
+            "name": _string(body.get("name"), "name", 300),
+            "product_type": product_type,
+            "owner_uid": owner,
+            "business_domain_uid": _uid(
+                body.get("business_domain_uid"), "business_domain_uid"
+            ),
+            "description": _string(body.get("description"), "description", 2000),
+            "quality_target": _number(
+                body.get("quality_target"), "quality_target", 0, 100
+            ),
+            "sla": _normalize_sla(body.get("sla")),
+            "status": "draft",
+            "current_version": 1,
+            "created_by": actor,
+            "created_at": now,
+            "updated_by": actor,
+            "updated_at": now,
+            "retired_at": None,
+        }
+        try:
+            result = self.repository.create_product(record)
+            self.repository.add_event(
+                record["uid"], "product_registered", actor, {}, version=1
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_products(self, **filters):
+        return self.repository.list_products(**filters)
+
+    def product_detail(self, uid: str):
+        result = self.repository.product_detail(_uid(uid, "product_uid"))
+        if result is None:
+            raise LookupError("governed product was not found")
+        return result
+
+    def transition_product(
+        self, uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"action", "reason"}, "product transition")
+        product_uid = _uid(uid, "product_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        product = self.repository.get_product(product_uid)
+        if product is None:
+            raise LookupError("governed product was not found")
+        if actor != product["owner_uid"]:
+            raise PermissionError("only the product owner can transition lifecycle")
+        action = _string(body.get("action"), "action", 30)
+        transitions = {
+            ("draft", "submit_review"): ("in_review", "product_submitted"),
+            ("in_review", "activate"): ("active", "product_activated"),
+            ("draft", "activate"): ("active", "product_activated"),
+            ("active", "suspend"): ("suspended", "product_suspended"),
+            ("suspended", "reactivate"): ("active", "product_reactivated"),
+            ("draft", "retire"): ("retired", "product_retired"),
+            ("in_review", "retire"): ("retired", "product_retired"),
+            ("active", "retire"): ("retired", "product_retired"),
+            ("suspended", "retire"): ("retired", "product_retired"),
+        }
+        target = transitions.get((product["status"], action))
+        if target is None:
+            raise RuntimeError("product lifecycle transition is not allowed")
+        if action in {"activate", "reactivate"}:
+            contract = self.repository.contract_for_product(product_uid)
+            certificate = self.repository.latest_certificate(product_uid)
+            if not contract or contract["status"] != "active":
+                raise RuntimeError("an active data contract is required")
+            if not certificate or certificate["status"] != "qualified":
+                raise RuntimeError("a qualified product certificate is required")
+        now = self.now_factory().isoformat()
+        product.update(
+            {
+                "status": target[0],
+                "updated_by": actor,
+                "updated_at": now,
+                "retired_at": now if target[0] == "retired" else None,
+            }
+        )
+        try:
+            result = self.repository.update_product(
+                product,
+                int(expected_version),
+                target[1],
+                actor,
+                {"reason": _string(body.get("reason"), "reason", 1000)},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def create_application(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {
+                "title",
+                "source_type",
+                "source_ref",
+                "business_domain_uid",
+                "purpose",
+                "requested_fields",
+            },
+            "product application",
+        )
+        source_type = _string(body.get("source_type"), "source_type", 30)
+        if source_type not in PRODUCT_TYPES:
+            raise ValueError("unsupported application source type")
+        source_ref = body.get("source_ref")
+        if not isinstance(source_ref, dict) or not source_ref:
+            raise ValueError("source_ref must be a non-empty object")
+        actor = _uid(actor_uid, "actor_uid")
+        if self.repository.users_available({actor}) != {actor}:
+            raise ValueError("application requester is unavailable")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "application_code": f"DPA-{self.now_factory():%Y%m%d}-{self.uid_factory()[-8:].upper()}",
+            "title": _string(body.get("title"), "title", 300),
+            "source_type": source_type,
+            "source_ref": copy.deepcopy(source_ref),
+            "business_domain_uid": _uid(
+                body.get("business_domain_uid"), "business_domain_uid"
+            ),
+            "purpose": _string(body.get("purpose"), "purpose", 1000),
+            "requested_fields": sorted(
+                {
+                    _string(item, "requested field", 200)
+                    for item in _list(
+                        body.get("requested_fields"), "requested_fields", minimum=1
+                    )
+                }
+            ),
+            "status": "draft",
+            "approval_task_uid": None,
+            "product_uid": None,
+            "current_version": 1,
+            "created_by": actor,
+            "created_at": now,
+            "updated_by": actor,
+            "updated_at": now,
+        }
+        try:
+            result = self.repository.create_application(record)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_applications(self, **filters):
+        return self.repository.list_applications(**filters)
+
+    def submit_application(
+        self, uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"workflow_uid"}, "application submission")
+        application_uid = _uid(uid, "application_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        application = self.repository.get_application(application_uid)
+        if application is None:
+            raise LookupError("product application was not found")
+        if application["status"] != "draft" or application["created_by"] != actor:
+            raise PermissionError("only the requester can submit a draft application")
+        task = self.approval_gateway.create_product_task(
+            application,
+            _uid(body.get("workflow_uid"), "workflow_uid"),
+            actor,
+        )
+        application.update(
+            {
+                "status": "pending_approval",
+                "approval_task_uid": task["uid"],
+                "updated_by": actor,
+                "updated_at": self.now_factory().isoformat(),
+            }
+        )
+        try:
+            result = self.repository.update_application(
+                application,
+                int(expected_version),
+                "application_submitted",
+                actor,
+                {"approval_task_uid": task["uid"]},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def reconcile_application(
+        self, uid: str, *, expected_version: int, actor_uid: str
+    ):
+        application_uid = _uid(uid, "application_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        application = self.repository.get_application(application_uid)
+        if application is None:
+            raise LookupError("product application was not found")
+        if application["status"] != "pending_approval":
+            raise RuntimeError("application is not waiting for approval")
+        task = self.approval_gateway.get_task(application["approval_task_uid"])
+        if not task or task["status"] not in {"approved", "rejected"}:
+            raise RuntimeError("approval task has no final decision")
+        application.update(
+            {
+                "status": task["status"],
+                "updated_by": actor,
+                "updated_at": self.now_factory().isoformat(),
+            }
+        )
+        try:
+            result = self.repository.update_application(
+                application,
+                int(expected_version),
+                f"application_{task['status']}",
+                actor,
+                {"approval_task_uid": task["uid"]},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def fulfill_application(
+        self,
+        uid: str,
+        product_uid: str,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        application_uid = _uid(uid, "application_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        application = self.repository.get_application(application_uid)
+        product = self.repository.get_product(_uid(product_uid, "product_uid"))
+        if application is None or product is None:
+            raise LookupError("application or governed product was not found")
+        if application["status"] != "approved":
+            raise RuntimeError("only an approved application can be fulfilled")
+        application.update(
+            {
+                "status": "fulfilled",
+                "product_uid": product["uid"],
+                "updated_by": actor,
+                "updated_at": self.now_factory().isoformat(),
+            }
+        )
+        try:
+            result = self.repository.update_application(
+                application,
+                int(expected_version),
+                "application_fulfilled",
+                actor,
+                {"product_uid": product["uid"], "grants_data_access": False},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def create_contract(self, product_uid: str, payload: Any, *, actor_uid: str):
+        uid = _uid(product_uid, "product_uid")
+        product = self.repository.get_product(uid)
+        if product is None:
+            raise LookupError("governed product was not found")
+        actor = _uid(actor_uid, "actor_uid")
+        if actor != product["owner_uid"]:
+            raise PermissionError("only the product owner can author a contract")
+        if self.repository.contract_for_product(uid):
+            raise RuntimeError("the product already has a data contract")
+        definition = _normalize_contract(payload)
+        now = self.now_factory().isoformat()
+        contract_uid = self.uid_factory()
+        version = {
+            "uid": self.uid_factory(),
+            "contract_uid": contract_uid,
+            "version": 1,
+            "status": "draft",
+            "definition": definition,
+            "content_hash": _canonical_hash(definition),
+            "compatibility": {"compatible": True, "breaking_changes": [], "mode": definition["compatibility_mode"]},
+            "created_by": actor,
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        contract = {
+            "uid": contract_uid,
+            "product_uid": uid,
+            "contract_code": f"DPC-{product['product_code']}",
+            "status": "draft",
+            "current_version": 1,
+            "active_version_uid": None,
+            "created_by": actor,
+            "created_at": now,
+            "updated_by": actor,
+            "updated_at": now,
+            "terminated_at": None,
+            "termination_reason": None,
+        }
+        try:
+            self.repository.create_contract(contract, version)
+            self.repository.add_event(uid, "contract_created", actor, {"contract_uid": contract_uid})
+            self.commit()
+            return {**contract, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def check_contract_compatibility(self, contract_uid: str, payload: Any):
+        uid = _uid(contract_uid, "contract_uid")
+        contract = self.repository.get_contract(uid)
+        if contract is None:
+            raise LookupError("data contract was not found")
+        candidate = _normalize_contract(payload)
+        active = self.repository.active_contract_version(uid)
+        if active is None:
+            return {"compatible": True, "breaking_changes": [], "mode": candidate["compatibility_mode"]}
+        return _compatibility(active["definition"], candidate)
+
+    def revise_contract(
+        self, contract_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        uid = _uid(contract_uid, "contract_uid")
+        contract = self.repository.get_contract(uid)
+        if contract is None:
+            raise LookupError("data contract was not found")
+        product = self.repository.get_product(contract["product_uid"])
+        actor = _uid(actor_uid, "actor_uid")
+        if actor != product["owner_uid"] or contract["status"] == "terminated":
+            raise PermissionError("contract cannot be revised by this actor")
+        definition = _normalize_contract(payload)
+        compatibility = self.check_contract_compatibility(uid, payload)
+        next_version = int(expected_version) + 1
+        now = self.now_factory().isoformat()
+        version = {
+            "uid": self.uid_factory(),
+            "contract_uid": uid,
+            "version": next_version,
+            "status": "draft",
+            "definition": definition,
+            "content_hash": _canonical_hash(definition),
+            "compatibility": compatibility,
+            "created_by": actor,
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        revised = {
+            **contract,
+            "current_version": next_version,
+            "updated_by": actor,
+            "updated_at": now,
+        }
+        try:
+            self.repository.revise_contract(revised, version, int(expected_version))
+            self.repository.add_event(
+                contract["product_uid"],
+                "contract_revised",
+                actor,
+                {"contract_uid": uid, "version": next_version, **compatibility},
+            )
+            self.commit()
+            return {**revised, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def publish_contract(
+        self, contract_uid: str, *, expected_version: int, actor_uid: str
+    ):
+        uid = _uid(contract_uid, "contract_uid")
+        contract = self.repository.get_contract(uid)
+        if contract is None:
+            raise LookupError("data contract was not found")
+        product = self.repository.get_product(contract["product_uid"])
+        actor = _uid(actor_uid, "actor_uid")
+        if actor != product["owner_uid"]:
+            raise PermissionError("only the product owner can publish a contract")
+        if int(contract["current_version"]) != int(expected_version):
+            raise RuntimeError("contract version conflict")
+        version = self.repository.contract_version(uid, int(expected_version))
+        if version is None or version["status"] != "draft":
+            raise RuntimeError("contract version is not publishable")
+        if not version["compatibility"]["compatible"]:
+            raise RuntimeError("breaking contract version cannot be published")
+        now = self.now_factory().isoformat()
+        version.update(
+            {"status": "published", "published_by": actor, "published_at": now}
+        )
+        published = {
+            **contract,
+            "status": "active",
+            "active_version_uid": version["uid"],
+            "updated_by": actor,
+            "updated_at": now,
+        }
+        try:
+            self.repository.publish_contract(published, version, int(expected_version))
+            self.repository.add_event(
+                contract["product_uid"],
+                "contract_published",
+                actor,
+                {"contract_uid": uid, "version": version["version"]},
+            )
+            self.commit()
+            return {**published, "active_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def terminate_contract(
+        self, contract_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"reason", "evidence_refs"}, "contract termination")
+        uid = _uid(contract_uid, "contract_uid")
+        contract = self.repository.get_contract(uid)
+        if contract is None:
+            raise LookupError("data contract was not found")
+        product = self.repository.get_product(contract["product_uid"])
+        actor = _uid(actor_uid, "actor_uid")
+        if actor != product["owner_uid"] or contract["status"] != "active":
+            raise PermissionError("active contract cannot be terminated by this actor")
+        evidence = [
+            _string(item, "evidence reference", 1000)
+            for item in _list(body.get("evidence_refs"), "evidence_refs", minimum=1)
+        ]
+        now = self.now_factory().isoformat()
+        terminated = {
+            **contract,
+            "status": "terminated",
+            "updated_by": actor,
+            "updated_at": now,
+            "terminated_at": now,
+            "termination_reason": _string(body.get("reason"), "reason", 1000),
+        }
+        try:
+            self.repository.terminate_contract(terminated, int(expected_version))
+            self.repository.add_event(
+                contract["product_uid"],
+                "contract_terminated",
+                actor,
+                {"reason": terminated["termination_reason"], "evidence_refs": evidence},
+            )
+            self.commit()
+            return terminated
+        except Exception:
+            self.rollback()
+            raise
+
+    def generate_certificate(self, product_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(payload, {"approval_task_uid", "evidence"}, "certificate request")
+        uid = _uid(product_uid, "product_uid")
+        product = self.repository.get_product(uid)
+        if product is None:
+            raise LookupError("governed product was not found")
+        actor = _uid(actor_uid, "actor_uid")
+        if actor != product["owner_uid"]:
+            raise PermissionError("only the product owner can issue a certificate")
+        contract = self.repository.contract_for_product(uid)
+        if not contract or contract["status"] != "active":
+            raise RuntimeError("an active data contract is required")
+        approval_uid = _uid(body.get("approval_task_uid"), "approval_task_uid")
+        approval = self.approval_gateway.get_task(approval_uid)
+        if not approval or approval["status"] != "approved":
+            raise RuntimeError("approved work-center evidence is required")
+        references = _closed(
+            body.get("evidence"),
+            {
+                "quality_run_uid",
+                "lineage_uids",
+                "rule_version_uids",
+                "workflow_run_uids",
+            },
+            "certificate evidence",
+        )
+        references = {
+            "quality_run_uid": _uid(references.get("quality_run_uid"), "quality_run_uid"),
+            "lineage_uids": [_uid(item, "lineage_uid") for item in _list(references.get("lineage_uids"), "lineage_uids", 1)],
+            "rule_version_uids": [_uid(item, "rule_version_uid") for item in _list(references.get("rule_version_uids"), "rule_version_uids", 1)],
+            "workflow_run_uids": [_uid(item, "workflow_run_uid") for item in _list(references.get("workflow_run_uids"), "workflow_run_uids", 1)],
+        }
+        snapshot = self.repository.evidence_snapshot(references)
+        quality = snapshot.get("quality") or {}
+        sla_events = snapshot.get("sla_events") or []
+        qualified = (
+            quality.get("status") == "success"
+            and float(quality.get("score", -1)) >= float(product["quality_target"])
+            and sla_events
+            and all(item.get("status") in {"met", "recovered"} for item in sla_events)
+            and snapshot.get("lineage")
+            and all(item.get("parse_status") == "resolved" for item in snapshot["lineage"])
+            and snapshot.get("rule_versions")
+            and all(item.get("status") == "published" for item in snapshot["rule_versions"])
+            and snapshot.get("workflow_runs")
+            and all(item.get("status") == "success" for item in snapshot["workflow_runs"])
+        )
+        now = self.now_factory().isoformat()
+        certificate_body = {
+            "product_uid": uid,
+            "contract_uid": contract["uid"],
+            "contract_version": contract["current_version"],
+            "approval_task_uid": approval_uid,
+            "evidence_snapshot": snapshot,
+            "status": "qualified" if qualified else "unqualified",
+        }
+        record = {
+            "uid": self.uid_factory(),
+            "certificate_code": f"DPCERT-{self.now_factory():%Y%m%d}-{self.uid_factory()[-8:].upper()}",
+            **certificate_body,
+            "content_hash": _canonical_hash(certificate_body),
+            "issued_by": actor,
+            "issued_at": now,
+        }
+        try:
+            result = self.repository.create_certificate(record)
+            self.repository.add_event(
+                uid,
+                "certificate_issued",
+                actor,
+                {"certificate_uid": record["uid"], "status": record["status"]},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def create_feedback(self, product_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"category", "rating", "summary", "details"},
+            "product feedback",
+        )
+        uid = _uid(product_uid, "product_uid")
+        if self.repository.get_product(uid) is None:
+            raise LookupError("governed product was not found")
+        actor = _uid(actor_uid, "actor_uid")
+        if self.repository.users_available({actor}) != {actor}:
+            raise ValueError("feedback author is unavailable")
+        category = _string(body.get("category"), "category", 30)
+        if category not in FEEDBACK_CATEGORIES:
+            raise ValueError("unsupported feedback category")
+        try:
+            rating = int(body.get("rating"))
+        except (TypeError, ValueError) as error:
+            raise ValueError("rating must be an integer") from error
+        if rating < 1 or rating > 5:
+            raise ValueError("rating is outside the allowed range")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "product_uid": uid,
+            "category": category,
+            "rating": rating,
+            "summary": _string(body.get("summary"), "summary", 300),
+            "details": _string(body.get("details"), "details", 2000),
+            "status": "open",
+            "assignee_uid": None,
+            "resolution": None,
+            "evidence_refs": [],
+            "current_version": 1,
+            "created_by": actor,
+            "created_at": now,
+            "updated_by": actor,
+            "updated_at": now,
+            "closed_at": None,
+        }
+        try:
+            result = self.repository.create_feedback(record)
+            self.repository.add_event(uid, "feedback_created", actor, {"feedback_uid": record["uid"]})
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def transition_feedback(
+        self, feedback_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(
+            payload,
+            {"action", "assignee_uid", "note", "evidence_refs"},
+            "feedback transition",
+        )
+        uid = _uid(feedback_uid, "feedback_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        feedback = self.repository.get_feedback(uid)
+        if feedback is None:
+            raise LookupError("product feedback was not found")
+        product = self.repository.get_product(feedback["product_uid"])
+        action = _string(body.get("action"), "action", 30)
+        transitions = {
+            ("open", "triage"): ("triaged", "feedback_triaged"),
+            ("triaged", "start"): ("in_progress", "feedback_started"),
+            ("in_progress", "resolve"): ("resolved", "feedback_resolved"),
+            ("resolved", "close"): ("closed", "feedback_closed"),
+            ("closed", "reopen"): ("open", "feedback_reopened"),
+        }
+        target = transitions.get((feedback["status"], action))
+        if target is None:
+            raise RuntimeError("feedback transition is not allowed")
+        if action in {"triage", "start", "resolve"} and actor not in {
+            product["owner_uid"],
+            feedback.get("assignee_uid"),
+        }:
+            raise PermissionError("only the owner or assignee can improve feedback")
+        if action in {"close", "reopen"} and actor not in {
+            product["owner_uid"],
+            feedback["created_by"],
+        }:
+            raise PermissionError("only the owner or author can confirm feedback")
+        note = _string(body.get("note"), "note", 2000)
+        if action == "triage":
+            assignee = _uid(body.get("assignee_uid"), "assignee_uid")
+            if self.repository.users_available({assignee}) != {assignee}:
+                raise ValueError("feedback assignee is unavailable")
+            feedback["assignee_uid"] = assignee
+        evidence = [
+            _string(item, "evidence reference", 1000)
+            for item in body.get("evidence_refs", [])
+        ]
+        if action == "resolve" and not evidence:
+            raise ValueError("feedback resolution requires evidence")
+        now = self.now_factory().isoformat()
+        feedback.update(
+            {
+                "status": target[0],
+                "resolution": note if action == "resolve" else feedback.get("resolution"),
+                "evidence_refs": evidence if action == "resolve" else feedback.get("evidence_refs", []),
+                "updated_by": actor,
+                "updated_at": now,
+                "closed_at": now if action == "close" else None,
+            }
+        )
+        try:
+            result = self.repository.update_feedback(
+                feedback,
+                int(expected_version),
+                target[1],
+                actor,
+                {"note": note, "evidence_refs": evidence},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def dashboard(self):
+        return self.repository.dashboard()

+ 817 - 0
deployment/app/core/data_service/product_governance_repository.py

@@ -0,0 +1,817 @@
+"""PostgreSQL repository and work-center adapter for product governance."""
+
+from __future__ import annotations
+
+import copy
+import json
+from datetime import datetime
+from decimal import Decimal
+from typing import Any
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.events.outbox import enqueue_outbox
+from app.core.governance.work_center import UnifiedWorkCenterService
+from app.core.governance.work_center_repository import SqlAlchemyWorkCenterRepository
+
+
+def _plain(row) -> dict[str, Any]:
+    result = dict(row)
+    for key, value in tuple(result.items()):
+        if value is None:
+            continue
+        if isinstance(value, datetime):
+            result[key] = value.isoformat()
+        elif isinstance(value, Decimal):
+            result[key] = float(value)
+        elif key.endswith("uid") or key in {
+            "uid",
+            "created_by",
+            "updated_by",
+            "issued_by",
+            "published_by",
+        }:
+            result[key] = str(value)
+    return result
+
+
+class WorkCenterProductApprovalGateway:
+    """Adapt product applications to the P2-WP07 unified task contract."""
+
+    def __init__(self, session):
+        self.repository = SqlAlchemyWorkCenterRepository(session)
+        self.service = UnifiedWorkCenterService(self.repository, rollback=session.rollback)
+
+    def create_product_task(self, application, workflow_uid, actor_uid):
+        return self.service.create_task(
+            {
+                "workflow_uid": workflow_uid,
+                "task_type": "data_product_approval",
+                "subject_type": "data_product",
+                "subject_uid": application["uid"],
+                "source_type": "data_product_application",
+                "source_uid": application["uid"],
+                "title": application["title"],
+                "description": application["purpose"],
+                "priority": "medium",
+                "business_domain_uid": application["business_domain_uid"],
+                "context": {
+                    "business_domain_uid": application["business_domain_uid"],
+                    "risk_level": "low",
+                },
+            },
+            actor_uid=actor_uid,
+        )
+
+    def get_task(self, uid):
+        return self.repository.get_task(uid)
+
+
+class SqlAlchemyProductGovernanceRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def users_available(self, user_uids):
+        values = sorted(set(user_uids))
+        if not values:
+            return set()
+        rows = self.session.execute(
+            text(
+                "SELECT id::text FROM public.users "
+                "WHERE status = 'active' AND id::text = ANY(:uids)"
+            ),
+            {"uids": values},
+        )
+        return {str(row[0]) for row in rows}
+
+    def legacy_product_exists(self, product_id):
+        return bool(
+            self.session.execute(
+                text("SELECT 1 FROM public.data_products WHERE id = :id"),
+                {"id": int(product_id)},
+            ).scalar()
+        )
+
+    def _product_select(self):
+        return """
+            SELECT uid::text AS uid, legacy_product_id, product_code, name,
+                   product_type, owner_uid::text AS owner_uid,
+                   business_domain_uid::text AS business_domain_uid,
+                   description, quality_target, sla, status, current_version,
+                   created_by::text AS created_by, created_at,
+                   updated_by::text AS updated_by, updated_at, retired_at
+            FROM public.governed_data_products
+        """
+
+    def create_product(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governed_data_products (
+                    uid, legacy_product_id, product_code, name, product_type,
+                    owner_uid, business_domain_uid, description, quality_target,
+                    sla, status, current_version, created_by, created_at,
+                    updated_by, updated_at, retired_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :legacy_product_id, :product_code, :name,
+                    :product_type, CAST(:owner_uid AS uuid),
+                    CAST(:business_domain_uid AS uuid), :description,
+                    :quality_target, CAST(:sla AS jsonb), :status,
+                    :current_version, CAST(:created_by AS uuid), :created_at,
+                    CAST(:updated_by AS uuid), :updated_at, :retired_at
+                )
+                """
+            ),
+            {**record, "sla": json.dumps(record["sla"], ensure_ascii=False)},
+        )
+        return copy.deepcopy(record)
+
+    def get_product(self, uid):
+        row = (
+            self.session.execute(
+                text(self._product_select() + " WHERE uid = CAST(:uid AS uuid)"),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def list_products(self, **filters):
+        clauses = []
+        params = {}
+        for key in ("status", "product_type", "business_domain_uid", "owner_uid"):
+            value = filters.get(key)
+            if not value:
+                continue
+            if key.endswith("uid"):
+                clauses.append(f"{key} = CAST(:{key} AS uuid)")
+            else:
+                clauses.append(f"{key} = :{key}")
+            params[key] = value
+        where = " WHERE " + " AND ".join(clauses) if clauses else ""
+        rows = self.session.execute(
+            text(self._product_select() + where + " ORDER BY updated_at DESC, uid DESC"),
+            params,
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def update_product(self, record, expected_version, action, actor_uid, payload):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.governed_data_products SET
+                    status = :status, current_version = current_version + 1,
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at,
+                    retired_at = :retired_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**record, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("product version conflict")
+        saved = self.get_product(record["uid"])
+        self.add_event(
+            saved["uid"], action, actor_uid, payload, saved["current_version"]
+        )
+        return saved
+
+    def add_event(self, product_uid, action, actor_uid, payload, version=1):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_governance_events (
+                    uid, product_uid, product_version, action,
+                    actor_uid, payload, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:product_uid AS uuid), :version,
+                    :action, CAST(:actor_uid AS uuid), CAST(:payload AS jsonb),
+                    CURRENT_TIMESTAMP
+                )
+                """
+            ),
+            {
+                "uid": new_governance_uid(),
+                "product_uid": product_uid,
+                "version": max(1, int(version)),
+                "action": action,
+                "actor_uid": actor_uid,
+                "payload": json.dumps(payload, ensure_ascii=False),
+            },
+        )
+        event_type = {
+            "contract_published": "data_product.contract.published",
+            "contract_terminated": "data_product.contract.terminated",
+            "certificate_issued": "data_product.certificate.issued",
+            "feedback_resolved": "data_product.feedback.resolved",
+        }.get(action, "data_product.governance.evidence.recorded")
+        enqueue_outbox(
+            self.session,
+            aggregate_type="governed_data_product",
+            aggregate_id=product_uid,
+            event_type=event_type,
+            payload={
+                "product_uid": product_uid,
+                "action": action,
+                "evidence": payload,
+                "grants_data_access": False,
+            },
+        )
+
+    def _application_select(self):
+        return """
+            SELECT uid::text AS uid, application_code, title, source_type,
+                   source_ref, business_domain_uid::text AS business_domain_uid,
+                   purpose, requested_fields, status,
+                   approval_task_uid::text AS approval_task_uid,
+                   product_uid::text AS product_uid, current_version,
+                   created_by::text AS created_by, created_at,
+                   updated_by::text AS updated_by, updated_at
+            FROM public.data_product_applications
+        """
+
+    def create_application(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_applications (
+                    uid, application_code, title, source_type, source_ref,
+                    business_domain_uid, purpose, requested_fields, status,
+                    approval_task_uid, product_uid, current_version,
+                    created_by, created_at, updated_by, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :application_code, :title, :source_type,
+                    CAST(:source_ref AS jsonb), CAST(:business_domain_uid AS uuid),
+                    :purpose, CAST(:requested_fields AS jsonb), :status,
+                    CAST(:approval_task_uid AS uuid), CAST(:product_uid AS uuid),
+                    :current_version, CAST(:created_by AS uuid), :created_at,
+                    CAST(:updated_by AS uuid), :updated_at
+                )
+                """
+            ),
+            {
+                **record,
+                "source_ref": json.dumps(record["source_ref"], ensure_ascii=False),
+                "requested_fields": json.dumps(record["requested_fields"], ensure_ascii=False),
+            },
+        )
+        self._application_event(record, "application_created", record["created_by"], {})
+        return copy.deepcopy(record)
+
+    def get_application(self, uid):
+        row = (
+            self.session.execute(
+                text(self._application_select() + " WHERE uid = CAST(:uid AS uuid)"),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def list_applications(self, **filters):
+        clauses = []
+        params = {}
+        if filters.get("status"):
+            clauses.append("status = :status")
+            params["status"] = filters["status"]
+        requester_uid = filters.get("requester_uid")
+        if requester_uid and not filters.get("can_manage"):
+            clauses.append("created_by = CAST(:requester_uid AS uuid)")
+            params["requester_uid"] = requester_uid
+        where = " WHERE " + " AND ".join(clauses) if clauses else ""
+        rows = self.session.execute(
+            text(self._application_select() + where + " ORDER BY updated_at DESC, uid DESC"),
+            params,
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def update_application(self, record, expected_version, action, actor_uid, payload):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_applications SET
+                    status = :status,
+                    approval_task_uid = CAST(:approval_task_uid AS uuid),
+                    product_uid = CAST(:product_uid AS uuid),
+                    current_version = current_version + 1,
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**record, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("application version conflict")
+        saved = self.get_application(record["uid"])
+        self._application_event(saved, action, actor_uid, payload)
+        if saved.get("product_uid"):
+            self.add_event(saved["product_uid"], action, actor_uid, payload)
+        return saved
+
+    def _application_event(self, application, action, actor_uid, payload):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_application_events (
+                    uid, application_uid, application_version, action,
+                    actor_uid, payload, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:application_uid AS uuid), :version,
+                    :action, CAST(:actor_uid AS uuid), CAST(:payload AS jsonb),
+                    CURRENT_TIMESTAMP
+                )
+                """
+            ),
+            {
+                "uid": new_governance_uid(),
+                "application_uid": application["uid"],
+                "version": application["current_version"],
+                "action": action,
+                "actor_uid": actor_uid,
+                "payload": json.dumps(payload, ensure_ascii=False),
+            },
+        )
+
+    def _contract_select(self):
+        return """
+            SELECT uid::text AS uid, product_uid::text AS product_uid,
+                   contract_code, status, current_version,
+                   active_version_uid::text AS active_version_uid,
+                   created_by::text AS created_by, created_at,
+                   updated_by::text AS updated_by, updated_at,
+                   terminated_at, termination_reason
+            FROM public.data_product_contracts
+        """
+
+    def create_contract(self, contract, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_contracts (
+                    uid, product_uid, contract_code, status, current_version,
+                    active_version_uid, created_by, created_at, updated_by,
+                    updated_at, terminated_at, termination_reason
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:product_uid AS uuid), :contract_code,
+                    :status, :current_version, NULL, CAST(:created_by AS uuid),
+                    :created_at, CAST(:updated_by AS uuid), :updated_at,
+                    :terminated_at, :termination_reason
+                )
+                """
+            ),
+            contract,
+        )
+        self._insert_contract_version(version)
+        return copy.deepcopy(contract)
+
+    def _insert_contract_version(self, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_contract_versions (
+                    uid, contract_uid, version, status, definition,
+                    content_hash, compatibility, created_by, created_at,
+                    published_by, published_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:contract_uid AS uuid), :version,
+                    :status, CAST(:definition AS jsonb), :content_hash,
+                    CAST(:compatibility AS jsonb), CAST(:created_by AS uuid),
+                    :created_at, CAST(:published_by AS uuid), :published_at
+                )
+                """
+            ),
+            {
+                **version,
+                "definition": json.dumps(version["definition"], ensure_ascii=False),
+                "compatibility": json.dumps(version["compatibility"], ensure_ascii=False),
+            },
+        )
+
+    def get_contract(self, uid):
+        row = (
+            self.session.execute(
+                text(self._contract_select() + " WHERE uid = CAST(:uid AS uuid)"),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def contract_for_product(self, product_uid):
+        row = (
+            self.session.execute(
+                text(self._contract_select() + " WHERE product_uid = CAST(:uid AS uuid)"),
+                {"uid": product_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def contract_version(self, contract_uid, version):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, contract_uid::text AS contract_uid,
+                           version, status, definition, content_hash, compatibility,
+                           created_by::text AS created_by, created_at,
+                           published_by::text AS published_by, published_at
+                    FROM public.data_product_contract_versions
+                    WHERE contract_uid = CAST(:uid AS uuid) AND version = :version
+                    """
+                ),
+                {"uid": contract_uid, "version": version},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def active_contract_version(self, contract_uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT v.uid::text AS uid, v.contract_uid::text AS contract_uid,
+                           v.version, v.status, v.definition, v.content_hash,
+                           v.compatibility, v.created_by::text AS created_by,
+                           v.created_at, v.published_by::text AS published_by,
+                           v.published_at
+                    FROM public.data_product_contracts c
+                    JOIN public.data_product_contract_versions v
+                      ON v.uid = c.active_version_uid
+                    WHERE c.uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": contract_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def revise_contract(self, contract, version, expected_version):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_contracts SET
+                    current_version = :current_version,
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**contract, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("contract version conflict")
+        self._insert_contract_version(version)
+        return self.get_contract(contract["uid"])
+
+    def publish_contract(self, contract, version, expected_version):
+        if int(contract["current_version"]) != int(expected_version):
+            raise RuntimeError("contract version conflict")
+        self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_contract_versions SET status = 'superseded'
+                WHERE contract_uid = CAST(:uid AS uuid) AND status = 'published'
+                """
+            ),
+            {"uid": contract["uid"]},
+        )
+        changed_version = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_contract_versions SET
+                    status = 'published', published_by = CAST(:published_by AS uuid),
+                    published_at = :published_at
+                WHERE uid = CAST(:uid AS uuid) AND status = 'draft'
+                """
+            ),
+            version,
+        )
+        changed_contract = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_contracts SET
+                    status = 'active', active_version_uid = CAST(:active_version_uid AS uuid),
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**contract, "expected_version": expected_version},
+        )
+        if changed_version.rowcount != 1 or changed_contract.rowcount != 1:
+            raise RuntimeError("contract version conflict")
+        return self.get_contract(contract["uid"])
+
+    def terminate_contract(self, contract, expected_version):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_contracts SET
+                    status = 'terminated', updated_by = CAST(:updated_by AS uuid),
+                    updated_at = :updated_at, terminated_at = :terminated_at,
+                    termination_reason = :termination_reason
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                  AND status = 'active'
+                """
+            ),
+            {**contract, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("contract version conflict")
+        return self.get_contract(contract["uid"])
+
+    def evidence_snapshot(self, references):
+        quality = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, status, score, batch_key,
+                           template_version_uid::text AS template_version_uid,
+                           asset_uid::text AS asset_uid, source_uid::text AS source_uid,
+                           created_at
+                    FROM public.quality_profile_runs
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": references["quality_run_uid"]},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if quality is None:
+            raise LookupError("quality run evidence was not found")
+        sla_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, sla_type, status, severity,
+                       actual, threshold, escalation_level, created_at
+                FROM public.quality_sla_events
+                WHERE run_uid = CAST(:uid AS uuid) ORDER BY sla_type
+                """
+            ),
+            {"uid": references["quality_run_uid"]},
+        ).mappings()
+        lineage_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, parse_status, source_asset,
+                       source_field, target_asset, target_field,
+                       relation_type, failure_reason, created_at
+                FROM public.active_metadata_lineage
+                WHERE uid::text = ANY(:uids) ORDER BY uid
+                """
+            ),
+            {"uids": references["lineage_uids"]},
+        ).mappings()
+        rule_rows = self.session.execute(
+            text(
+                """
+                SELECT id::text AS uid, rule_uid::text AS rule_uid,
+                       version_no AS version, spec_hash, status, published_at
+                FROM public.data_rule_versions
+                WHERE id::text = ANY(:uids) ORDER BY id
+                """
+            ),
+            {"uids": references["rule_version_uids"]},
+        ).mappings()
+        workflow_rows = self.session.execute(
+            text(
+                """
+                SELECT id::text AS uid, workflow_version_id::text AS workflow_version_uid,
+                       engine_type, trigger_type, status, started_at, finished_at, created_at
+                FROM public.workflow_runs
+                WHERE id::text = ANY(:uids) ORDER BY id
+                """
+            ),
+            {"uids": references["workflow_run_uids"]},
+        ).mappings()
+        lineage = [_plain(row) for row in lineage_rows]
+        rules = [_plain(row) for row in rule_rows]
+        workflows = [_plain(row) for row in workflow_rows]
+        if len(lineage) != len(set(references["lineage_uids"])):
+            raise LookupError("lineage evidence was not found")
+        if len(rules) != len(set(references["rule_version_uids"])):
+            raise LookupError("rule version evidence was not found")
+        if len(workflows) != len(set(references["workflow_run_uids"])):
+            raise LookupError("workflow run evidence was not found")
+        return {
+            "quality": _plain(quality),
+            "sla_events": [_plain(row) for row in sla_rows],
+            "lineage": lineage,
+            "rule_versions": rules,
+            "workflow_runs": workflows,
+        }
+
+    def create_certificate(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_certificates (
+                    uid, certificate_code, product_uid, contract_uid,
+                    contract_version, approval_task_uid, evidence_snapshot,
+                    status, content_hash, issued_by, issued_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :certificate_code,
+                    CAST(:product_uid AS uuid), CAST(:contract_uid AS uuid),
+                    :contract_version, CAST(:approval_task_uid AS uuid),
+                    CAST(:evidence_snapshot AS jsonb), :status, :content_hash,
+                    CAST(:issued_by AS uuid), :issued_at
+                )
+                """
+            ),
+            {
+                **record,
+                "evidence_snapshot": json.dumps(record["evidence_snapshot"], ensure_ascii=False),
+            },
+        )
+        return copy.deepcopy(record)
+
+    def latest_certificate(self, product_uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, certificate_code,
+                           product_uid::text AS product_uid,
+                           contract_uid::text AS contract_uid, contract_version,
+                           approval_task_uid::text AS approval_task_uid,
+                           evidence_snapshot, status, content_hash,
+                           issued_by::text AS issued_by, issued_at
+                    FROM public.data_product_certificates
+                    WHERE product_uid = CAST(:uid AS uuid)
+                    ORDER BY issued_at DESC, uid DESC LIMIT 1
+                    """
+                ),
+                {"uid": product_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def create_feedback(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_product_feedback (
+                    uid, product_uid, category, rating, summary, details,
+                    status, assignee_uid, resolution, evidence_refs,
+                    current_version, created_by, created_at,
+                    updated_by, updated_at, closed_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:product_uid AS uuid), :category,
+                    :rating, :summary, :details, :status,
+                    CAST(:assignee_uid AS uuid), :resolution,
+                    CAST(:evidence_refs AS jsonb), :current_version,
+                    CAST(:created_by AS uuid), :created_at,
+                    CAST(:updated_by AS uuid), :updated_at, :closed_at
+                )
+                """
+            ),
+            {**record, "evidence_refs": json.dumps(record["evidence_refs"])},
+        )
+        return copy.deepcopy(record)
+
+    def get_feedback(self, uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, product_uid::text AS product_uid,
+                           category, rating, summary, details, status,
+                           assignee_uid::text AS assignee_uid, resolution,
+                           evidence_refs, current_version,
+                           created_by::text AS created_by, created_at,
+                           updated_by::text AS updated_by, updated_at, closed_at
+                    FROM public.data_product_feedback
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def update_feedback(self, record, expected_version, action, actor_uid, payload):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.data_product_feedback SET
+                    status = :status, assignee_uid = CAST(:assignee_uid AS uuid),
+                    resolution = :resolution, evidence_refs = CAST(:evidence_refs AS jsonb),
+                    current_version = current_version + 1,
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at,
+                    closed_at = :closed_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {
+                **record,
+                "evidence_refs": json.dumps(record["evidence_refs"]),
+                "expected_version": expected_version,
+            },
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("feedback version conflict")
+        saved = self.get_feedback(record["uid"])
+        self.add_event(saved["product_uid"], action, actor_uid, payload)
+        return saved
+
+    def product_detail(self, uid):
+        product = self.get_product(uid)
+        if product is None:
+            return None
+        contract = self.contract_for_product(uid)
+        versions = []
+        if contract:
+            rows = self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, contract_uid::text AS contract_uid,
+                           version, status, definition, content_hash, compatibility,
+                           created_by::text AS created_by, created_at,
+                           published_by::text AS published_by, published_at
+                    FROM public.data_product_contract_versions
+                    WHERE contract_uid = CAST(:uid AS uuid)
+                    ORDER BY version DESC
+                    """
+                ),
+                {"uid": contract["uid"]},
+            ).mappings()
+            versions = [_plain(row) for row in rows]
+        certificate_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, certificate_code,
+                       product_uid::text AS product_uid,
+                       contract_uid::text AS contract_uid, contract_version,
+                       approval_task_uid::text AS approval_task_uid,
+                       evidence_snapshot, status, content_hash,
+                       issued_by::text AS issued_by, issued_at
+                FROM public.data_product_certificates
+                WHERE product_uid = CAST(:uid AS uuid)
+                ORDER BY issued_at DESC
+                """
+            ),
+            {"uid": uid},
+        ).mappings()
+        feedback_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, product_uid::text AS product_uid,
+                       category, rating, summary, details, status,
+                       assignee_uid::text AS assignee_uid, resolution,
+                       evidence_refs, current_version,
+                       created_by::text AS created_by, created_at,
+                       updated_by::text AS updated_by, updated_at, closed_at
+                FROM public.data_product_feedback
+                WHERE product_uid = CAST(:uid AS uuid)
+                ORDER BY updated_at DESC
+                """
+            ),
+            {"uid": uid},
+        ).mappings()
+        event_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, product_uid::text AS product_uid,
+                       product_version, action, actor_uid::text AS actor_uid,
+                       payload, created_at
+                FROM public.data_product_governance_events
+                WHERE product_uid = CAST(:uid AS uuid)
+                ORDER BY created_at, uid
+                """
+            ),
+            {"uid": uid},
+        ).mappings()
+        return {
+            **product,
+            "contract": {**contract, "versions": versions} if contract else None,
+            "certificates": [_plain(row) for row in certificate_rows],
+            "feedback": [_plain(row) for row in feedback_rows],
+            "timeline": [_plain(row) for row in event_rows],
+        }
+
+    def dashboard(self):
+        row = self.session.execute(
+            text(
+                """
+                SELECT
+                    (SELECT COUNT(*) FROM public.governed_data_products) AS product_count,
+                    (SELECT COUNT(*) FROM public.governed_data_products WHERE status = 'active') AS active_count,
+                    (SELECT COUNT(*) FROM public.data_product_applications WHERE status = 'pending_approval') AS application_pending_count,
+                    (SELECT COUNT(*) FROM public.data_product_feedback WHERE status <> 'closed') AS open_feedback_count
+                """
+            )
+        ).mappings().one()
+        return {key: int(value or 0) for key, value in row.items()}

+ 21 - 0
deployment/app/core/system/permissions.py

@@ -61,6 +61,9 @@ DATA_OBSERVABILITY_MANAGE = "data-observability:manage"
 WORK_CENTER_READ = "governance:work-center:read"
 WORK_CENTER_OPERATE = "governance:work-center:operate"
 WORK_CENTER_MANAGE = "governance:work-center:manage"
+DATA_PRODUCTS_READ = "data-products:read"
+DATA_PRODUCTS_OPERATE = "data-products:operate"
+DATA_PRODUCTS_MANAGE = "data-products:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -72,6 +75,7 @@ ROLE_PERMISSIONS = {
             ACTIVE_METADATA_READ,
             DATA_OBSERVABILITY_READ,
             WORK_CENTER_READ,
+            DATA_PRODUCTS_READ,
         }
     ),
     "editor": frozenset(
@@ -103,6 +107,8 @@ ROLE_PERMISSIONS = {
             DATA_OBSERVABILITY_OPERATE,
             WORK_CENTER_READ,
             WORK_CENTER_OPERATE,
+            DATA_PRODUCTS_READ,
+            DATA_PRODUCTS_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -161,6 +167,9 @@ ROLE_PERMISSIONS = {
             WORK_CENTER_READ,
             WORK_CENTER_OPERATE,
             WORK_CENTER_MANAGE,
+            DATA_PRODUCTS_READ,
+            DATA_PRODUCTS_OPERATE,
+            DATA_PRODUCTS_MANAGE,
         }
     ),
 }
@@ -190,6 +199,18 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if method == "GET":
             return (GOVERNANCE_AUDIT_READ,)
         return (GOVERNANCE_AUDIT_SEAL,)
+    if path.startswith("/api/dataservice/governance"):
+        if method == "GET":
+            return (DATA_PRODUCTS_READ,)
+        if any(
+            marker in path
+            for marker in (
+                "/reconcile",
+                "/fulfill",
+            )
+        ) or path == "/api/dataservice/governance/products":
+            return (DATA_PRODUCTS_MANAGE,)
+        return (DATA_PRODUCTS_OPERATE,)
     if path.startswith("/api/meta/domain-templates"):
         if method == "GET":
             return (DOMAIN_TEMPLATES_READ,)

+ 225 - 0
deployment/migrations/versions/20260802_440_product_governance.py

@@ -0,0 +1,225 @@
+"""Add governed data products, applications, contracts and certificates."""
+
+from alembic import op
+
+revision = "20260802_440"
+down_revision = "20260802_430"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.governed_data_products (
+            uid UUID PRIMARY KEY,
+            legacy_product_id INTEGER NOT NULL UNIQUE
+                REFERENCES public.data_products(id) ON DELETE RESTRICT,
+            product_code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            product_type VARCHAR(30) NOT NULL CHECK (
+                product_type IN ('database','api','file','data_product')
+            ),
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            business_domain_uid UUID NOT NULL,
+            description VARCHAR(2000) NOT NULL,
+            quality_target NUMERIC(6,2) NOT NULL CHECK (
+                quality_target >= 0 AND quality_target <= 100
+            ),
+            sla JSONB NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','in_review','active','suspended','retired')
+            ),
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            retired_at TIMESTAMPTZ,
+            CHECK (jsonb_typeof(sla) = 'object')
+        );
+        CREATE INDEX idx_governed_product_domain_status
+            ON public.governed_data_products(
+                business_domain_uid, status, updated_at DESC
+            );
+        CREATE INDEX idx_governed_product_owner_status
+            ON public.governed_data_products(owner_uid, status, updated_at DESC);
+
+        CREATE TABLE public.data_product_governance_events (
+            uid UUID PRIMARY KEY,
+            product_uid UUID NOT NULL
+                REFERENCES public.governed_data_products(uid) ON DELETE CASCADE,
+            product_version INTEGER NOT NULL CHECK (product_version > 0),
+            action VARCHAR(50) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(payload) = 'object')
+        );
+        CREATE INDEX idx_product_governance_timeline
+            ON public.data_product_governance_events(product_uid, created_at, uid);
+
+        CREATE TABLE public.data_product_applications (
+            uid UUID PRIMARY KEY,
+            application_code VARCHAR(40) NOT NULL UNIQUE,
+            title VARCHAR(300) NOT NULL,
+            source_type VARCHAR(30) NOT NULL CHECK (
+                source_type IN ('database','api','file','data_product')
+            ),
+            source_ref JSONB NOT NULL,
+            business_domain_uid UUID NOT NULL,
+            purpose VARCHAR(1000) NOT NULL,
+            requested_fields JSONB NOT NULL,
+            status VARCHAR(30) NOT NULL CHECK (
+                status IN (
+                    'draft','pending_approval','approved','rejected',
+                    'fulfilled','cancelled'
+                )
+            ),
+            approval_task_uid UUID REFERENCES public.governance_tasks(uid),
+            product_uid UUID REFERENCES public.governed_data_products(uid),
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(source_ref) = 'object'),
+            CHECK (jsonb_typeof(requested_fields) = 'array')
+        );
+        CREATE INDEX idx_product_application_worklist
+            ON public.data_product_applications(
+                created_by, status, updated_at DESC
+            );
+        CREATE INDEX idx_product_application_domain
+            ON public.data_product_applications(
+                business_domain_uid, status, updated_at DESC
+            );
+
+        CREATE TABLE public.data_product_application_events (
+            uid UUID PRIMARY KEY,
+            application_uid UUID NOT NULL
+                REFERENCES public.data_product_applications(uid) ON DELETE CASCADE,
+            application_version INTEGER NOT NULL CHECK (application_version > 0),
+            action VARCHAR(50) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(payload) = 'object')
+        );
+        CREATE INDEX idx_product_application_timeline
+            ON public.data_product_application_events(
+                application_uid, created_at, uid
+            );
+
+        CREATE TABLE public.data_product_contracts (
+            uid UUID PRIMARY KEY,
+            product_uid UUID NOT NULL UNIQUE
+                REFERENCES public.governed_data_products(uid) ON DELETE RESTRICT,
+            contract_code VARCHAR(180) NOT NULL UNIQUE,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','active','terminated')
+            ),
+            current_version INTEGER NOT NULL CHECK (current_version > 0),
+            active_version_uid UUID,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            terminated_at TIMESTAMPTZ,
+            termination_reason VARCHAR(1000)
+        );
+
+        CREATE TABLE public.data_product_contract_versions (
+            uid UUID PRIMARY KEY,
+            contract_uid UUID NOT NULL
+                REFERENCES public.data_product_contracts(uid) ON DELETE RESTRICT,
+            version INTEGER NOT NULL CHECK (version > 0),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','superseded')
+            ),
+            definition JSONB NOT NULL,
+            content_hash CHAR(64) NOT NULL,
+            compatibility JSONB NOT NULL,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            published_by UUID REFERENCES public.users(id),
+            published_at TIMESTAMPTZ,
+            UNIQUE (contract_uid, version),
+            UNIQUE (contract_uid, content_hash),
+            CHECK (jsonb_typeof(definition) = 'object'),
+            CHECK (jsonb_typeof(compatibility) = 'object')
+        );
+        ALTER TABLE public.data_product_contracts
+            ADD CONSTRAINT data_product_contract_active_version_fk
+            FOREIGN KEY (active_version_uid)
+            REFERENCES public.data_product_contract_versions(uid);
+        CREATE UNIQUE INDEX uq_data_product_contract_published_version
+            ON public.data_product_contract_versions(contract_uid)
+            WHERE status = 'published';
+
+        CREATE TABLE public.data_product_certificates (
+            uid UUID PRIMARY KEY,
+            certificate_code VARCHAR(50) NOT NULL UNIQUE,
+            product_uid UUID NOT NULL
+                REFERENCES public.governed_data_products(uid) ON DELETE RESTRICT,
+            contract_uid UUID NOT NULL
+                REFERENCES public.data_product_contracts(uid) ON DELETE RESTRICT,
+            contract_version INTEGER NOT NULL CHECK (contract_version > 0),
+            approval_task_uid UUID NOT NULL REFERENCES public.governance_tasks(uid),
+            evidence_snapshot JSONB NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('qualified','unqualified')
+            ),
+            content_hash CHAR(64) NOT NULL,
+            issued_by UUID NOT NULL REFERENCES public.users(id),
+            issued_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (product_uid, content_hash),
+            CHECK (jsonb_typeof(evidence_snapshot) = 'object')
+        );
+        CREATE INDEX idx_product_certificate_latest
+            ON public.data_product_certificates(product_uid, issued_at DESC);
+
+        CREATE TABLE public.data_product_feedback (
+            uid UUID PRIMARY KEY,
+            product_uid UUID NOT NULL
+                REFERENCES public.governed_data_products(uid) ON DELETE CASCADE,
+            category VARCHAR(30) NOT NULL CHECK (
+                category IN (
+                    'quality','freshness','usability',
+                    'documentation','service'
+                )
+            ),
+            rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
+            summary VARCHAR(300) NOT NULL,
+            details VARCHAR(2000) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('open','triaged','in_progress','resolved','closed')
+            ),
+            assignee_uid UUID REFERENCES public.users(id),
+            resolution VARCHAR(2000),
+            evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            closed_at TIMESTAMPTZ,
+            CHECK (jsonb_typeof(evidence_refs) = 'array')
+        );
+        CREATE INDEX idx_product_feedback_worklist
+            ON public.data_product_feedback(
+                product_uid, status, updated_at DESC
+            );
+        CREATE INDEX idx_product_feedback_assignee
+            ON public.data_product_feedback(
+                assignee_uid, status, updated_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "governed product contracts, certificates and feedback are retained; "
+        "downgrade requires an approved archival migration"
+    )

+ 14 - 5
docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md

@@ -389,11 +389,11 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 
 **主要工作:**
 
-- [ ] 补齐数据产品责任人、生命周期、质量和 SLA。
-- [ ] 支持数据库、API、文件和数据产品的通用申请单及多级审批。
-- [ ] 建设数据合同、版本、兼容检查、变更通知和终止。
-- [ ] 生成包含质量、血缘、规则版本、运行批次和审批证据的产品合格证。
-- [ ] 建设用户反馈和产品改进闭环。
+- [x] 补齐数据产品责任人、生命周期、质量和 SLA。
+- [x] 支持数据库、API、文件和数据产品的通用申请单及多级审批。
+- [x] 建设数据合同、版本、兼容检查、变更通知和终止。
+- [x] 生成包含质量、血缘、规则版本、运行批次和审批证据的产品合格证。
+- [x] 建设用户反馈和产品改进闭环。
 
 **主要文件区域:**
 
@@ -405,6 +405,15 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 **完成门禁:** 至少一个第二业务域数据产品完成登记、申请、审批、合同、合格证和
 反馈流程;不包含自动授权下发、查询网关和动态脱敏。
 
+**工程状态:** 已完成本地工程门禁。已有数据产品可增量登记为受责任人、生命周期、
+质量目标和 SLA 约束的治理对象;数据库、API、文件和数据产品四类申请共用统一申请契约,
+并复用 P2-WP07 统一工作中心完成版本化审批。数据合同支持不可变版本、向后/完全兼容检查、
+发布、变更事件和证据化终止;产品合格证只引用 canonical 质量、SLA、血缘、已发布规则、
+成功运行批次和已批准任务。第二业务域“设备运行与维护”样本已在 PostgreSQL 完成登记、
+API 申请、审批、履约、合同、合格证、激活和反馈关闭。自动授权下发、查询网关和动态脱敏
+均未建设;正式产品、责任人、流程、证据及容量/安全 UAT 仍需企业环境绑定。详见
+`docs/phase2/P2_WP08_DATA_PRODUCT_GOVERNANCE.md`。
+
 ### P2-WP09 Agent 基础治理
 
 **目标:** 管理平台内部 Agent 的身份、权限、风险和证据。

+ 6 - 6
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -620,15 +620,15 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | MKT-04 | 数据市场 / 产品血缘 | 产品到 DataFlow、业务域、元数据和数据源血缘 | 已建设 |
 | MKT-05 | 数据市场 / 数据订单 | 新建、修改、分析、审批、驳回、交付和完成 | 已建设 |
 | MKT-06 | 数据发现 / 产品推荐 | 根据自然语言、角色、业务域和用途推荐数据产品 | 部分建设 |
-| MKT-07 | 数据申请 / 通用申请单 | 数据库、API、文件和数据产品统一申请 | 规划中 |
-| MKT-08 | 数据申请 / 多级审批 | 按业务域、敏感级别、用途和期限路由审批 | 规划中 |
+| MKT-07 | 数据申请 / 通用申请单 | 数据库、API、文件和数据产品统一申请 | 工程完成,待企业对象与字段目录验收 |
+| MKT-08 | 数据申请 / 多级审批 | 按业务域、敏感级别、用途和期限路由审批 | 工程完成,复用统一工作中心;待正式流程与参与人验收 |
 | MKT-09 | 数据授权 / 自动下发 | 向数据库、API、文件服务或外部 IAM 下发权限 | 规划中 |
 | MKT-10 | 数据授权 / 访问网关 | 统一认证、查询代理和访问策略执行 | 规划中 |
 | MKT-11 | 数据授权 / 行列权限 | 行级、列级、字段级和条件访问控制 | 规划中 |
 | MKT-12 | 数据授权 / 动态脱敏 | 按用户、用途和环境应用脱敏策略 | 规划中 |
 | MKT-13 | 数据订阅 / 周期订阅 | 定时交付、事件订阅、失败重试和送达状态 | 规划中 |
-| MKT-14 | 数据合同 / 合同定义 | Schema、质量、SLA、用途、责任和兼容性契约 | 规划中 |
-| MKT-15 | 数据合同 / 合同版本 | 版本、变更通知、兼容检查、接受和终止 | 规划中 |
+| MKT-14 | 数据合同 / 合同定义 | Schema、质量、SLA、用途、责任和兼容性契约 | 工程完成,待企业合同模板验收 |
+| MKT-15 | 数据合同 / 合同版本 | 版本、变更通知、兼容检查、接受和终止 | 工程完成,待外部通知消费与终止 UAT |
 | MKT-16 | 数据交付 / 到期回收 | 权限到期、订阅终止、自动回收和复核 | 规划中 |
 | MKT-17 | 数据使用 / 用途限制 | 使用目的、允许环境、期限和禁止二次传播 | 规划中 |
 | MKT-18 | 数据使用 / 异常检测 | 非常规访问、超量使用和策略违约 | 规划中 |
@@ -636,8 +636,8 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | MKT-20 | 成本治理 / 成本中心 | 按部门、业务域、项目和成本中心归集 | 规划中 |
 | MKT-21 | 成本治理 / 分摊规则 | 存储、计算、交付和模型成本内部 Showback/Chargeback | 规划中 |
 | MKT-22 | 成本治理 / 预算配额 | 预算、配额、超限预警和内部结算报表 | 规划中 |
-| MKT-23 | 可信交付 / 产品合格证 | 质量、血缘、规则版本、运行批次和授权证据 | 部分建设 |
-| MKT-24 | 数据市场 / 用户反馈 | 评分、评论、问题反馈和产品改进闭环 | 规划中 |
+| MKT-23 | 可信交付 / 产品合格证 | 质量、血缘、规则版本、运行批次和审批证据 | 工程完成,待企业真实证据与产品样本验收 |
+| MKT-24 | 数据市场 / 用户反馈 | 评分、评论、问题反馈和产品改进闭环 | 工程完成,待企业改进责任人与服务流程验收 |
 
 ### 12.8 数据规则、生产线与数据工厂
 

+ 33 - 0
docs/architecture/DATA_MODEL.md

@@ -406,6 +406,38 @@ canonical 数据。画像与异常只用于数据运营与治理,不提供任
 工作台按用户参与范围过滤任务,管理员才能配置流程、模板、超时批次和外部邮件投递。
 当前只提供 SMTP 绑定和可审计重试,不包含企业微信、飞书、钉钉或 ITSM 正式连接器。
 
+## 4.9 P2-WP08 基础数据产品治理
+
+产品治理层以已有 `data_products` 为生产结果身份,不复制既有产品数据。每个已有产品最多
+登记一个 `governed_data_products` 治理对象,补充稳定产品编码、产品类型、业务域、责任人、
+质量目标、SLA 和版本化生命周期。产品只有同时存在活动数据合同和合格产品证书时,才允许
+从草稿/评审或暂停状态激活。
+
+| 数据对象 | 作用 | 关键约束 |
+|---|---|---|
+| `governed_data_products` | 已有产品的治理当前态 | 旧产品一对一;显式责任人、业务域、质量、SLA 和乐观版本 |
+| `data_product_governance_events` | 产品治理不可变时间线 | 登记、生命周期、合同、合格证和反馈动作均追加证据及 outbox |
+| `data_product_applications` | 数据库、API、文件和产品的通用申请 | 申请人范围隔离;审批任务与产品履约分别显式关联 |
+| `data_product_application_events` | 申请状态时间线 | 提交、审批同步和履约逐版本追加,不覆盖历史 |
+| `data_product_contracts` | 产品合同身份与活动版本 | 一个产品最多一份合同;草稿、活动和终止状态显式区分 |
+| `data_product_contract_versions` | 不可变合同定义 | Schema、交付、质量、SLA、用途及兼容模式以 canonical JSON 哈希保存 |
+| `data_product_certificates` | 产品质量与交付证据快照 | 关联活动合同和已批准任务;相同内容哈希不重复签发 |
+| `data_product_feedback` | 用户反馈与改进当前态 | 分诊、处理、解决、用户确认关闭;解决必须附证据 |
+
+通用申请复用 P2-WP07 已发布流程和统一任务,不在产品模块复制审批引擎。审批通过后,产品
+模块必须显式同步审批结果并完成履约;履约事件固定记录 `grants_data_access=false`,不会向
+数据库、API、文件服务或 IAM 下发权限。
+
+合同兼容检查在向后兼容模式下拒绝删除字段、改变类型或收紧可空性,在完全兼容模式下还
+拒绝新增非空字段;兼容性失败时不能发布。合同发布、终止、合格证签发和反馈解决写出产品
+治理 outbox 事件,供通知或后续消费方使用,但不直接修改外部系统。
+
+合格证从 `quality_profile_runs`、`quality_sla_events`、`active_metadata_lineage`、
+`data_rule_versions`、`workflow_runs` 和统一工作中心读取 canonical 证据。质量运行必须成功且
+达到产品目标,SLA 必须达标或恢复,血缘必须解析成功,规则必须已发布,运行批次必须成功,
+审批任务必须已批准;任一门禁不满足时证书为不合格。申请审批或合格证都不替代数据授权,
+查询网关、行列权限和动态脱敏仍属于后续安全与交付能力。
+
 ## 5. 所有权与删除规则
 
 - PostgreSQL 是身份、权限、映射、任务状态、布局和一致性事件的源真相。
@@ -428,6 +460,7 @@ canonical 数据。画像与异常只用于数据运营与治理,不提供任
 - 通用质量模板、版本、画像批次、字段指标、异常发现和 SLA 事件以 PostgreSQL 为源真相;主动元数据资产是质量对象身份和根因证据的权威来源。复发次数和升级级别为确定性运营证据,不等同于自动因果结论或自动修复授权。
 - SLO、源事件消费账本、聚合告警、数据事故、影响、处置时间线和复盘以 PostgreSQL 为源真相;采集和质量原始事件仍由其原表负责。事故恢复不等于关闭,缺少责任、影响或关闭证据时必须失败关闭,事故证据不得物理删除。
 - 通用流程版本、统一任务、参与人、审批、评论、附件引用、处理时间线、通知模板、偏好和送达尝试以 PostgreSQL 为源真相;质量问题、术语标准、数据产品和 Agent 本身的状态仍归各自源模块。工作中心只写处理证据和 outbox 回执,不把审批结果直接冒充源模块状态。
+- 已有 `data_products` 继续作为产品生产结果,产品治理登记、申请、申请事件、合同及不可变版本、合格证、反馈和治理时间线以 PostgreSQL 为源真相。合格证保存权威证据快照而非复制原始资产;申请履约不自动授予数据访问权。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 517 - 1
docs/architecture/OPENAPI.yaml

@@ -3,7 +3,7 @@ info:
   title: "DataOps Platform API(当前代码基线)"
   version: "2026-07-16"
   description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"
-x-route-count: 334
+x-route-count: 352
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -1135,6 +1135,522 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/applications":
+    get:
+      tags: [data_service]
+      operationId: data_service_list_product_applications_get
+      summary: "list product applications"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [data_service]
+      operationId: data_service_create_product_application_post
+      summary: "create product application"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/applications/{application_uid}/fulfill":
+    post:
+      tags: [data_service]
+      operationId: data_service_fulfill_product_application_post
+      summary: "fulfill product application"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: application_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/applications/{application_uid}/reconcile":
+    post:
+      tags: [data_service]
+      operationId: data_service_reconcile_product_application_post
+      summary: "reconcile product application"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: application_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/applications/{application_uid}/submit":
+    post:
+      tags: [data_service]
+      operationId: data_service_submit_product_application_post
+      summary: "submit product application"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: application_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/contracts/{contract_uid}/compatibility":
+    post:
+      tags: [data_service]
+      operationId: data_service_check_product_contract_compatibility_post
+      summary: "check product contract compatibility"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: contract_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/contracts/{contract_uid}/publish":
+    post:
+      tags: [data_service]
+      operationId: data_service_publish_product_contract_post
+      summary: "publish product contract"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: contract_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/contracts/{contract_uid}/revisions":
+    post:
+      tags: [data_service]
+      operationId: data_service_revise_product_contract_post
+      summary: "revise product contract"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: contract_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/contracts/{contract_uid}/terminate":
+    post:
+      tags: [data_service]
+      operationId: data_service_terminate_product_contract_post
+      summary: "terminate product contract"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: contract_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/dashboard":
+    get:
+      tags: [data_service]
+      operationId: data_service_product_governance_dashboard_get
+      summary: "product governance dashboard"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/feedback/{feedback_uid}/transition":
+    post:
+      tags: [data_service]
+      operationId: data_service_transition_product_feedback_post
+      summary: "transition product feedback"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: feedback_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/products":
+    get:
+      tags: [data_service]
+      operationId: data_service_list_governed_products_get
+      summary: "list governed products"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [data_service]
+      operationId: data_service_register_governed_product_post
+      summary: "register governed product"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/products/{product_uid}":
+    get:
+      tags: [data_service]
+      operationId: data_service_get_governed_product_get
+      summary: "get governed product"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: product_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/products/{product_uid}/certificates":
+    post:
+      tags: [data_service]
+      operationId: data_service_issue_product_certificate_post
+      summary: "issue product certificate"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: product_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/products/{product_uid}/contracts":
+    post:
+      tags: [data_service]
+      operationId: data_service_create_product_contract_post
+      summary: "create product contract"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: product_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/products/{product_uid}/feedback":
+    post:
+      tags: [data_service]
+      operationId: data_service_create_product_feedback_post
+      summary: "create product feedback"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: product_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/dataservice/governance/products/{product_uid}/transition":
+    post:
+      tags: [data_service]
+      operationId: data_service_transition_governed_product_post
+      summary: "transition governed product"
+      x-source: "app/api/data_service/product_governance_routes.py"
+      parameters:
+        - name: product_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/dataservice/neworder":
     post:
       tags: [data_service]

+ 79 - 0
docs/phase2/P2_WP08_DATA_PRODUCT_GOVERNANCE.md

@@ -0,0 +1,79 @@
+# P2-WP08 基础数据产品治理工程说明
+
+## 1. 完成范围
+
+P2-WP08 已完成本地工程实现和定向验证。平台保留已有数据产品和订单能力,在其上增加一层
+可审计的产品治理契约,不重建产品生产、查询或授权系统。
+
+本工作包完成:
+
+- 已有数据产品的一对一治理登记,补充产品责任人、业务域、生命周期、质量目标和 SLA;
+- 数据库、API、文件和数据产品四类通用申请,以及草稿、提交、审批同步和履约状态;
+- 复用 P2-WP07 统一工作中心的条件路由、多人审批和版本化处理证据;
+- 数据合同定义、不可变版本、兼容检查、发布、变更事件和证据化终止;
+- 汇总质量、SLA、血缘、规则版本、运行批次和审批证据的产品合格证;
+- 用户评分与问题反馈,以及分诊、处理、解决、证据和用户确认关闭;
+- 独立的“数据产品治理”页面、权限隔离接口、运营看板和治理详情视图。
+
+## 2. 产品与申请边界
+
+`governed_data_products` 通过外键引用已有 `data_products`,一个旧产品只能有一条治理登记。
+产品生命周期为 `draft → in_review → active → suspended/retired`;责任人是生命周期和合同的
+唯一操作主体。激活与重新激活必须同时存在活动合同和合格证,缺一失败关闭。
+
+通用申请使用 `source_type + source_ref` 表达数据库、API、文件或产品对象。申请提交时创建
+统一工作中心的 `data_product_approval` 任务;工作中心只保存审批证据,产品模块显式同步
+最终结果。审批后的“履约”只把申请关联到治理产品,并在审计中固定记录
+`grants_data_access=false`。本工作包没有自动授权、访问网关、行列权限或动态脱敏能力。
+
+## 3. 数据合同与产品合格证
+
+数据合同覆盖字段 Schema、交付方式、质量下限、SLA、允许用途、保留期限和兼容策略。
+每次修订新增不可变版本和 SHA-256 内容哈希。向后兼容拒绝删除字段、改变字段类型和收紧
+可空性;完全兼容还拒绝新增必填字段;存在破坏性变更的版本不能发布。合同终止必须提供
+原因和至少一条证据引用。
+
+产品合格证不接受客户端上传“已合格”结论。服务端按编号读取 canonical 证据并形成快照:
+
+- `quality_profile_runs`:运行成功且得分达到产品质量目标;
+- `quality_sla_events`:至少一项事件,且全部为达标或恢复;
+- `active_metadata_lineage`:引用的血缘全部解析成功;
+- `data_rule_versions`:引用的规则版本全部已发布;
+- `workflow_runs`:引用的运行批次全部成功;
+- P2-WP07 统一任务:审批状态必须为已批准。
+
+合同发布/终止、合格证签发和反馈解决均写出 `governed_data_product` outbox 事件,事件明确
+携带 `grants_data_access=false`。
+
+## 4. 反馈改进与并发
+
+反馈状态为 `open → triaged → in_progress → resolved → closed`,关闭后可以重开。责任人负责
+分诊、指派和改进,指派人可以处理,产品责任人或反馈作者负责关闭/重开。解决反馈必须保存
+说明和证据引用。产品、申请、合同和反馈的状态变更均使用 `If-Match` 乐观版本,避免并发
+覆盖;所有状态动作追加时间线,不物理覆盖历史证据。
+
+## 5. 权限与接口
+
+- `data-products:read`:查看治理产品、本人申请、产品详情和运营看板;
+- `data-products:operate`:发起/提交申请、创建/确认反馈,以及由产品责任人执行生命周期、
+  合同和合格证动作;
+- `data-products:manage`:登记产品、管理全局申请视图、同步审批结果和记录履约。
+
+前端保留原“数据产品”和“数据订单”菜单,并新增“数据产品治理”独立入口。管理功能按
+当前用户权限显示;看板统计直接来自 PostgreSQL,不以模拟数据或页面缓存代替。
+OpenAPI 已由当前源码重新生成,共 352 个操作,产品治理提供 18 个路由操作。
+
+## 6. 定向验证与剩余门禁
+
+本工作包按约束只运行本次变动相关验证:
+
+- 产品领域状态机、四类申请、合同兼容、证书门禁和反馈闭环测试;
+- API 角色、乐观版本、权限策略、迁移与前端契约测试;
+- 本次 Python 文件 Ruff、前端改动文件 ESLint 和前端生产构建;
+- 本地 PostgreSQL 从 `20260802_430` 实际升级到 `20260802_440`;
+- 第二业务域“设备运行与维护”真实 PostgreSQL 测试:完成产品登记、API 申请、统一审批、
+  履约、合同发布、合格证、激活、反馈改进关闭和 outbox 验证,测试事务结束后回滚样本。
+
+本地工程完成不等同企业验收或生产就绪。正式投产仍需绑定真实产品、业务域、责任人和企业
+审批参与人;配置正式合同模板与证据保留策略;完成容量、并发、告警、备份恢复和安全 UAT;
+并由后续工作包建设受控授权下发、查询网关及动态脱敏。

+ 26 - 0
frontend/src/api/productGovernance.js

@@ -0,0 +1,26 @@
+import http from '@/utils/request'
+
+const root = '/dataservice/governance'
+const versionHeaders = version => ({ headers: { 'If-Match': `"${version}"` } })
+
+export const getProductGovernanceDashboard = () => http.get(`${root}/dashboard`)
+export const listGovernedProducts = params => http.get(`${root}/products`, params)
+export const getGovernedProduct = uid => http.get(`${root}/products/${uid}`)
+export const registerGovernedProduct = payload => http.post(`${root}/products`, payload)
+export const transitionGovernedProduct = (uid, payload, version) => http.post(`${root}/products/${uid}/transition`, payload, versionHeaders(version))
+
+export const listProductApplications = params => http.get(`${root}/applications`, params)
+export const createProductApplication = payload => http.post(`${root}/applications`, payload)
+export const submitProductApplication = (uid, payload, version) => http.post(`${root}/applications/${uid}/submit`, payload, versionHeaders(version))
+export const reconcileProductApplication = (uid, version) => http.post(`${root}/applications/${uid}/reconcile`, {}, versionHeaders(version))
+export const fulfillProductApplication = (uid, productUid, version) => http.post(`${root}/applications/${uid}/fulfill`, { product_uid: productUid }, versionHeaders(version))
+
+export const createProductContract = (productUid, payload) => http.post(`${root}/products/${productUid}/contracts`, payload)
+export const checkProductContractCompatibility = (contractUid, payload) => http.post(`${root}/contracts/${contractUid}/compatibility`, payload)
+export const reviseProductContract = (contractUid, payload, version) => http.post(`${root}/contracts/${contractUid}/revisions`, payload, versionHeaders(version))
+export const publishProductContract = (contractUid, version) => http.post(`${root}/contracts/${contractUid}/publish`, {}, versionHeaders(version))
+export const terminateProductContract = (contractUid, payload, version) => http.post(`${root}/contracts/${contractUid}/terminate`, payload, versionHeaders(version))
+
+export const issueProductCertificate = (productUid, payload) => http.post(`${root}/products/${productUid}/certificates`, payload)
+export const createProductFeedback = (productUid, payload) => http.post(`${root}/products/${productUid}/feedback`, payload)
+export const transitionProductFeedback = (feedbackUid, payload, version) => http.post(`${root}/feedback/${feedbackUid}/transition`, payload, versionHeaders(version))

+ 44 - 0
frontend/src/router/routes.js

@@ -1727,6 +1727,50 @@ export default {
           alwaysShow: 0,
           metastr: '{"keepAlive":false,"allowClick":false,"enName":"data Product","editModules":false,"title":"数据产品","fullScreen":false,"target":false}',
           open: null
+        },
+        {
+          meun: '',
+          code: '',
+          hidden: 0,
+          rootId: 972,
+          icon: '',
+          remark: '',
+          type: 1,
+          title: '数据产品治理',
+          local: '',
+          path: '/dataService/productGovernance',
+          urls: '',
+          children: [],
+          enName: 'data product governance',
+          id: 976,
+          redirect: '',
+          level: 2,
+          openPath: '',
+          active: '',
+          label: '数据产品治理',
+          sort: 8,
+          parentId: 972,
+          effectiveStatus: true,
+          parentName: 'dataService',
+          component: 'dataService/productGovernance',
+          meta: {
+            keepAlive: false,
+            allowClick: false,
+            roles: [],
+            permissions: ['data-products:read'],
+            enName: 'data product governance',
+            icon: '',
+            editModules: false,
+            title: '数据产品治理',
+            fullScreen: false,
+            target: false,
+            effectiveStatus: true
+          },
+          name: 'productGovernance',
+          style: '',
+          alwaysShow: 0,
+          metastr: '{"keepAlive":false,"allowClick":false,"permissions":["data-products:read"],"enName":"data product governance","editModules":false,"title":"数据产品治理","fullScreen":false,"target":false}',
+          open: null
         }
       ],
       enName: 'data service',

+ 236 - 0
frontend/src/views/dataService/productGovernance/index.vue

@@ -0,0 +1,236 @@
+<template>
+  <div class="product-governance pa-4">
+    <section class="hero pa-5 mb-4">
+      <div>
+        <div class="eyebrow mb-2">DATA PRODUCT OPERATIONS</div>
+        <h1 class="text-h4 font-weight-bold mb-2">数据产品治理</h1>
+        <p class="mb-0">围绕责任人、申请审批、数据合同、产品合格证和用户反馈管理产品全生命周期。</p>
+      </div>
+      <div class="hero-actions">
+        <v-btn v-if="canOperate" color="white" outlined @click="openApplicationDialog">
+          <v-icon left>mdi-file-send-outline</v-icon>发起申请
+        </v-btn>
+        <v-btn v-if="canManage" color="white" class="ml-2 indigo--text" @click="registerVisible = true">
+          <v-icon left>mdi-package-variant-plus</v-icon>治理登记
+        </v-btn>
+        <v-btn icon color="white" class="ml-2" :loading="loading" @click="refreshAll"><v-icon>mdi-refresh</v-icon></v-btn>
+      </div>
+    </section>
+
+    <v-row class="mb-1">
+      <v-col v-for="card in summaryCards" :key="card.label" cols="6" md="3">
+        <v-card outlined class="metric-card pa-4">
+          <div class="text-caption grey--text text--darken-1">{{ card.label }}</div>
+          <div class="text-h4 font-weight-bold mt-1" :class="`${card.color}--text`">{{ card.value }}</div>
+          <div class="text-caption mt-1">{{ card.note }}</div>
+        </v-card>
+      </v-col>
+    </v-row>
+
+    <v-card outlined class="content-card">
+      <v-tabs v-model="tab" color="primary">
+        <v-tab>治理产品</v-tab>
+        <v-tab>通用申请</v-tab>
+        <v-tab>建设边界</v-tab>
+      </v-tabs>
+      <v-divider />
+      <v-tabs-items v-model="tab">
+        <v-tab-item>
+          <div class="toolbar pa-4">
+            <v-select v-model="productFilters.status" :items="productStatusOptions" label="生命周期" clearable dense outlined hide-details />
+            <v-select v-model="productFilters.product_type" :items="typeOptions" label="产品类型" clearable dense outlined hide-details />
+            <v-btn color="primary" depressed @click="loadProducts">查询</v-btn>
+          </div>
+          <v-data-table :headers="productHeaders" :items="products" :loading="loading" :items-per-page="10">
+            <template v-slot:[`item.product_type`]="{ item }"><v-chip x-small outlined>{{ typeLabel(item.product_type) }}</v-chip></template>
+            <template v-slot:[`item.status`]="{ item }"><v-chip small dark :color="statusColor(item.status)">{{ statusLabel(item.status) }}</v-chip></template>
+            <template v-slot:[`item.quality_target`]="{ item }">{{ item.quality_target }} 分</template>
+            <template v-slot:[`item.sla`]="{ item }">{{ item.sla && item.sla.freshness_hours }}h / {{ item.sla && item.sla.availability_target }}%</template>
+            <template v-slot:[`item.actions`]="{ item }"><v-btn text small color="primary" @click="openProduct(item)">治理详情</v-btn></template>
+            <template v-slot:no-data><div class="pa-8 grey--text">尚无治理登记的数据产品</div></template>
+          </v-data-table>
+        </v-tab-item>
+
+        <v-tab-item>
+          <div class="toolbar pa-4">
+            <v-select v-model="applicationStatus" :items="applicationStatusOptions" label="申请状态" clearable dense outlined hide-details />
+            <v-btn color="primary" depressed @click="loadApplications">查询</v-btn>
+            <v-spacer />
+            <v-btn v-if="canOperate" outlined color="primary" @click="openApplicationDialog">新建申请</v-btn>
+          </div>
+          <v-data-table :headers="applicationHeaders" :items="applications" :loading="loading" :items-per-page="10">
+            <template v-slot:[`item.source_type`]="{ item }">{{ typeLabel(item.source_type) }}</template>
+            <template v-slot:[`item.status`]="{ item }"><v-chip small outlined :color="statusColor(item.status)">{{ statusLabel(item.status) }}</v-chip></template>
+            <template v-slot:[`item.actions`]="{ item }">
+              <v-btn v-if="item.status === 'draft' && canOperate" text small color="primary" @click="submitApplication(item)">提交审批</v-btn>
+              <v-btn v-if="item.status === 'pending_approval' && canManage" text small color="primary" @click="reconcileApplication(item)">同步结果</v-btn>
+              <v-btn v-if="item.status === 'approved' && canManage" text small color="primary" @click="fulfillApplication(item)">记录履约</v-btn>
+            </template>
+            <template v-slot:no-data><div class="pa-8 grey--text">暂无产品申请</div></template>
+          </v-data-table>
+        </v-tab-item>
+
+        <v-tab-item>
+          <v-row class="pa-5">
+            <v-col cols="12" md="4"><v-card outlined class="boundary-card pa-4"><v-icon color="teal" large>mdi-check-decagram-outline</v-icon><h3 class="mt-3 mb-2">本期纳入</h3><p class="mb-0">产品责任与生命周期、四类通用申请、多级审批、数据合同及兼容检查、合格证、反馈改进闭环。</p></v-card></v-col>
+            <v-col cols="12" md="4"><v-card outlined class="boundary-card pa-4"><v-icon color="indigo" large>mdi-link-variant</v-icon><h3 class="mt-3 mb-2">证据复用</h3><p class="mb-0">合格证只引用质量、SLA、血缘、规则版本、运行批次和统一工作中心的真实证据。</p></v-card></v-col>
+            <v-col cols="12" md="4"><v-card outlined class="boundary-card pa-4"><v-icon color="orange" large>mdi-shield-lock-outline</v-icon><h3 class="mt-3 mb-2">明确不包含</h3><p class="mb-0">申请履约不会自动下发数据权限;查询网关和动态脱敏留待后续工作包建设。</p></v-card></v-col>
+          </v-row>
+        </v-tab-item>
+      </v-tabs-items>
+    </v-card>
+
+    <v-dialog v-model="detailVisible" max-width="980" scrollable>
+      <v-card v-if="detail">
+        <v-card-title class="detail-title">
+          <div><div class="text-caption grey--text">{{ detail.product_code }}</div>{{ detail.name }}</div>
+          <v-chip :color="statusColor(detail.status)" dark>{{ statusLabel(detail.status) }}</v-chip>
+        </v-card-title>
+        <v-card-text>
+          <v-alert text dense type="info">责任人 {{ detail.owner_uid }} · 产品版本 v{{ detail.current_version }} · 合格证是激活门禁,不代表自动授权。</v-alert>
+          <v-tabs v-model="detailTab" grow>
+            <v-tab>责任与合同</v-tab><v-tab>产品合格证</v-tab><v-tab>用户反馈</v-tab><v-tab>治理时间线</v-tab>
+          </v-tabs>
+          <v-tabs-items v-model="detailTab">
+            <v-tab-item class="pt-4">
+              <v-row><v-col cols="6" md="3"><strong>质量目标</strong><div>{{ detail.quality_target }} 分</div></v-col><v-col cols="6" md="3"><strong>新鲜度</strong><div>{{ detail.sla.freshness_hours }} 小时</div></v-col><v-col cols="6" md="3"><strong>可用性</strong><div>{{ detail.sla.availability_target }}%</div></v-col><v-col cols="6" md="3"><strong>支持等级</strong><div>{{ supportLabel(detail.sla.support_tier) }}</div></v-col></v-row>
+              <v-divider class="my-4" />
+              <div v-if="detail.contract">
+                <div class="d-flex align-center mb-3"><h3>数据合同 {{ detail.contract.contract_code }}</h3><v-spacer /><v-chip small outlined>{{ statusLabel(detail.contract.status) }}</v-chip></div>
+                <v-simple-table><thead><tr><th>版本</th><th>状态</th><th>兼容模式</th><th>发布时间</th></tr></thead><tbody><tr v-for="version in detail.contract.versions" :key="version.uid"><td>v{{ version.version }}</td><td>{{ statusLabel(version.status) }}</td><td>{{ version.compatibility.mode }}</td><td>{{ formatTime(version.published_at) }}</td></tr></tbody></v-simple-table>
+                <v-btn v-if="canGovernDetail && detail.contract.status === 'draft'" class="mt-3" color="primary" depressed @click="publishContract">发布当前合同</v-btn>
+              </div>
+              <v-alert v-else type="warning" text>尚未建立数据合同。合同发布后,才可签发产品合格证。</v-alert>
+              <v-btn v-if="canGovernDetail && !detail.contract" color="primary" outlined @click="openContractDialog">新建合同</v-btn>
+            </v-tab-item>
+            <v-tab-item class="pt-4">
+              <v-list two-line><v-list-item v-for="certificate in detail.certificates" :key="certificate.uid"><v-list-item-avatar><v-icon :color="certificate.status === 'qualified' ? 'success' : 'error'">mdi-certificate-outline</v-icon></v-list-item-avatar><v-list-item-content><v-list-item-title>{{ certificate.certificate_code }}</v-list-item-title><v-list-item-subtitle>合同 v{{ certificate.contract_version }} · {{ formatTime(certificate.issued_at) }}</v-list-item-subtitle></v-list-item-content><v-chip small :color="certificate.status === 'qualified' ? 'success' : 'error'" dark>{{ statusLabel(certificate.status) }}</v-chip></v-list-item><v-list-item v-if="!detail.certificates.length"><v-list-item-content class="grey--text text-center">尚未生成产品合格证</v-list-item-content></v-list-item></v-list>
+              <v-btn v-if="canGovernDetail && detail.contract && detail.contract.status === 'active'" color="primary" outlined @click="certificateVisible = true">生成合格证</v-btn>
+            </v-tab-item>
+            <v-tab-item class="pt-4">
+              <v-list two-line><v-list-item v-for="feedback in detail.feedback" :key="feedback.uid"><v-list-item-avatar><v-icon color="amber darken-2">mdi-message-alert-outline</v-icon></v-list-item-avatar><v-list-item-content><v-list-item-title>{{ feedback.summary }}</v-list-item-title><v-list-item-subtitle>{{ feedback.category }} · {{ feedback.rating }} 星 · {{ feedback.details }}</v-list-item-subtitle></v-list-item-content><v-list-item-action class="feedback-action"><v-chip small outlined>{{ statusLabel(feedback.status) }}</v-chip><v-btn v-if="canAdvanceFeedback(feedback)" text x-small color="primary" @click="advanceFeedback(feedback)">{{ feedbackAction(feedback.status).text }}</v-btn></v-list-item-action></v-list-item><v-list-item v-if="!detail.feedback.length"><v-list-item-content class="grey--text text-center">暂无用户反馈</v-list-item-content></v-list-item></v-list>
+              <v-btn v-if="canOperate" color="primary" outlined @click="feedbackVisible = true">提交反馈</v-btn>
+            </v-tab-item>
+            <v-tab-item class="pt-4"><v-timeline dense><v-timeline-item v-for="event in detail.timeline" :key="event.uid" small><strong>{{ eventLabel(event.action) }}</strong><div class="text-caption">v{{ event.product_version }} · {{ formatTime(event.created_at) }}</div></v-timeline-item></v-timeline></v-tab-item>
+          </v-tabs-items>
+        </v-card-text>
+        <v-card-actions>
+          <template v-if="canGovernDetail"><v-btn v-for="action in lifecycleActions(detail.status)" :key="action.value" text color="primary" @click="transitionProduct(action.value)">{{ action.text }}</v-btn></template>
+          <v-spacer /><v-btn text @click="detailVisible = false">关闭</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="registerVisible" max-width="720"><v-card><v-card-title>登记治理数据产品</v-card-title><v-card-text><v-row><v-col cols="6"><v-text-field v-model.number="registerForm.legacy_product_id" label="现有数据产品 ID" type="number" outlined dense /></v-col><v-col cols="6"><v-text-field v-model="registerForm.product_code" label="产品编码" outlined dense /></v-col><v-col cols="12"><v-text-field v-model="registerForm.name" label="产品名称" outlined dense /></v-col><v-col cols="6"><v-select v-model="registerForm.product_type" :items="typeOptions" label="产品类型" outlined dense /></v-col><v-col cols="6"><v-text-field v-model="registerForm.owner_uid" label="责任人 UID" outlined dense /></v-col><v-col cols="12"><v-text-field v-model="registerForm.business_domain_uid" label="业务域 UID" outlined dense /></v-col><v-col cols="12"><v-textarea v-model="registerForm.description" label="产品说明" outlined rows="2" /></v-col><v-col cols="4"><v-text-field v-model.number="registerForm.quality_target" label="质量目标" type="number" outlined dense /></v-col><v-col cols="4"><v-text-field v-model.number="registerForm.sla.freshness_hours" label="新鲜度(小时)" type="number" outlined dense /></v-col><v-col cols="4"><v-text-field v-model.number="registerForm.sla.availability_target" label="可用性(%)" type="number" outlined dense /></v-col></v-row></v-card-text><v-card-actions><v-spacer /><v-btn text @click="registerVisible = false">取消</v-btn><v-btn color="primary" depressed :loading="saving" @click="registerProduct">确认登记</v-btn></v-card-actions></v-card></v-dialog>
+
+    <v-dialog v-model="applicationVisible" max-width="680"><v-card><v-card-title>发起通用数据申请</v-card-title><v-card-text><v-text-field v-model="applicationForm.title" label="申请标题" outlined dense /><v-select v-model="applicationForm.source_type" :items="typeOptions" label="申请对象类型" outlined dense /><v-text-field v-model="applicationForm.sourceValue" label="对象标识(库表、API、文件或产品编码)" outlined dense /><v-text-field v-model="applicationForm.business_domain_uid" label="业务域 UID" outlined dense /><v-textarea v-model="applicationForm.purpose" label="使用目的" outlined rows="2" /><v-combobox v-model="applicationForm.requested_fields" label="申请字段(回车添加)" multiple chips outlined dense /><v-text-field v-model="applicationForm.workflow_uid" label="审批流程 UID" hint="创建草稿后可直接送入统一工作中心" persistent-hint outlined dense /></v-card-text><v-card-actions><v-spacer /><v-btn text @click="applicationVisible = false">取消</v-btn><v-btn color="primary" depressed :loading="saving" @click="createApplication">保存申请</v-btn></v-card-actions></v-card></v-dialog>
+
+    <v-dialog v-model="contractVisible" max-width="760"><v-card><v-card-title>建立数据合同</v-card-title><v-card-text><v-alert text dense type="info">每行定义一个字段;首版合同将进行结构、交付、质量、SLA 和用途约束。</v-alert><v-textarea v-model="contractForm.fields" label="字段定义:字段名,类型,是否可空" hint="例如 device_id,string,false" persistent-hint outlined rows="4" /><v-row><v-col cols="6"><v-select v-model="contractForm.deliveryMode" :items="deliveryModes" label="交付方式" outlined dense /></v-col><v-col cols="6"><v-text-field v-model="contractForm.deliveryFormat" label="交付格式" outlined dense /></v-col><v-col cols="6"><v-select v-model="contractForm.compatibilityMode" :items="compatibilityModes" label="兼容策略" outlined dense /></v-col><v-col cols="6"><v-text-field v-model.number="contractForm.retentionDays" label="保留天数" type="number" outlined dense /></v-col></v-row><v-textarea v-model="contractForm.purpose" label="允许用途" outlined rows="2" /><v-textarea v-model="contractForm.changeReason" label="合同说明" outlined rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn text @click="contractVisible = false">取消</v-btn><v-btn color="primary" depressed :loading="saving" @click="createContract">创建草案</v-btn></v-card-actions></v-card></v-dialog>
+
+    <v-dialog v-model="certificateVisible" max-width="700"><v-card><v-card-title>生成产品合格证</v-card-title><v-card-text><v-alert type="warning" text dense>所有编号都必须来自已落库的真实治理证据;系统会按产品质量门槛自动判定是否合格。</v-alert><v-text-field v-model="certificateForm.approval_task_uid" label="已批准任务 UID" outlined dense /><v-text-field v-model="certificateForm.quality_run_uid" label="质量运行 UID" outlined dense /><v-combobox v-model="certificateForm.lineage_uids" label="血缘 UID" multiple chips outlined dense /><v-combobox v-model="certificateForm.rule_version_uids" label="规则版本 UID" multiple chips outlined dense /><v-combobox v-model="certificateForm.workflow_run_uids" label="运行批次 UID" multiple chips outlined dense /></v-card-text><v-card-actions><v-spacer /><v-btn text @click="certificateVisible = false">取消</v-btn><v-btn color="primary" depressed :loading="saving" @click="issueCertificate">生成</v-btn></v-card-actions></v-card></v-dialog>
+
+    <v-dialog v-model="feedbackVisible" max-width="620"><v-card><v-card-title>提交产品反馈</v-card-title><v-card-text><v-select v-model="feedbackForm.category" :items="feedbackCategories" label="反馈类型" outlined dense /><v-rating v-model="feedbackForm.rating" color="amber" class="mb-4" /><v-text-field v-model="feedbackForm.summary" label="反馈摘要" outlined dense /><v-textarea v-model="feedbackForm.details" label="具体情况" outlined rows="3" /></v-card-text><v-card-actions><v-spacer /><v-btn text @click="feedbackVisible = false">取消</v-btn><v-btn color="primary" depressed :loading="saving" @click="createFeedback">提交</v-btn></v-card-actions></v-card></v-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  createProductApplication, createProductContract, createProductFeedback,
+  fulfillProductApplication,
+  getGovernedProduct, getProductGovernanceDashboard, issueProductCertificate,
+  listGovernedProducts, listProductApplications, publishProductContract,
+  reconcileProductApplication, registerGovernedProduct, submitProductApplication,
+  transitionGovernedProduct, transitionProductFeedback
+} from '@/api/productGovernance'
+
+const emptyApplication = () => ({ title: '', source_type: 'data_product', sourceValue: '', business_domain_uid: '', purpose: '', requested_fields: [], workflow_uid: '' })
+
+export default {
+  name: 'ProductGovernance',
+  data: () => ({
+    tab: 0,
+    detailTab: 0,
+    loading: false,
+    saving: false,
+    detailVisible: false,
+    registerVisible: false,
+    applicationVisible: false,
+    contractVisible: false,
+    certificateVisible: false,
+    feedbackVisible: false,
+    dashboard: {},
+    products: [],
+    applications: [],
+    detail: null,
+    productFilters: { status: null, product_type: null },
+    applicationStatus: null,
+    typeOptions: [{ text: '数据库', value: 'database' }, { text: 'API', value: 'api' }, { text: '文件', value: 'file' }, { text: '数据产品', value: 'data_product' }],
+    productStatusOptions: [{ text: '草稿', value: 'draft' }, { text: '评审中', value: 'in_review' }, { text: '已激活', value: 'active' }, { text: '已暂停', value: 'suspended' }, { text: '已退役', value: 'retired' }],
+    applicationStatusOptions: [{ text: '草稿', value: 'draft' }, { text: '审批中', value: 'pending_approval' }, { text: '已批准', value: 'approved' }, { text: '已驳回', value: 'rejected' }, { text: '已履约', value: 'fulfilled' }],
+    productHeaders: [{ text: '产品编码', value: 'product_code' }, { text: '产品名称', value: 'name' }, { text: '类型', value: 'product_type' }, { text: '生命周期', value: 'status' }, { text: '质量目标', value: 'quality_target' }, { text: 'SLA', value: 'sla' }, { text: '', value: 'actions', sortable: false }],
+    applicationHeaders: [{ text: '申请编号', value: 'application_code' }, { text: '申请标题', value: 'title' }, { text: '对象类型', value: 'source_type' }, { text: '状态', value: 'status' }, { text: '更新时间', value: 'updated_at' }, { text: '', value: 'actions', sortable: false }],
+    registerForm: { legacy_product_id: null, product_code: '', name: '', product_type: 'data_product', owner_uid: '', business_domain_uid: '', description: '', quality_target: 90, sla: { availability_target: 99, freshness_hours: 24, support_tier: 'business_hours' } },
+    applicationForm: emptyApplication(),
+    contractForm: { fields: 'id,string,false', deliveryMode: 'product', deliveryFormat: 'table', compatibilityMode: 'backward', retentionDays: 365, purpose: '', changeReason: '建立首版合同' },
+    certificateForm: { approval_task_uid: '', quality_run_uid: '', lineage_uids: [], rule_version_uids: [], workflow_run_uids: [] },
+    feedbackForm: { category: 'quality', rating: 5, summary: '', details: '' },
+    deliveryModes: [{ text: '数据表', value: 'table' }, { text: 'API', value: 'api' }, { text: '文件', value: 'file' }, { text: '产品', value: 'product' }],
+    compatibilityModes: [{ text: '向后兼容', value: 'backward' }, { text: '完全兼容', value: 'full' }, { text: '不检查', value: 'none' }],
+    feedbackCategories: [{ text: '质量', value: 'quality' }, { text: '新鲜度', value: 'freshness' }, { text: '易用性', value: 'usability' }, { text: '文档', value: 'documentation' }, { text: '服务', value: 'service' }]
+  }),
+  computed: {
+    permissions () { return (this.$store.state.user.userInfo || {}).permissions || [] },
+    canOperate () { return this.permissions.includes('data-products:operate') },
+    canManage () { return this.permissions.includes('data-products:manage') },
+    canGovernDetail () { return Boolean(this.detail && this.canOperate && this.detail.owner_uid === (this.$store.state.user.userInfo || {}).id) },
+    summaryCards () { return [{ label: '治理产品', value: this.dashboard.product_count || 0, note: '已纳入责任与合同约束', color: 'indigo' }, { label: '活跃产品', value: this.dashboard.active_count || 0, note: '已通过合同和合格证门禁', color: 'teal' }, { label: '待审批申请', value: this.dashboard.application_pending_count || 0, note: '在统一工作中心流转', color: 'blue' }, { label: '开放反馈', value: this.dashboard.open_feedback_count || 0, note: '等待改进或用户确认', color: 'deep-orange' }] }
+  },
+  created () { this.refreshAll() },
+  methods: {
+    async refreshAll () { this.loading = true; try { const [dashboard, products, applications] = await Promise.all([getProductGovernanceDashboard(), listGovernedProducts(this.productFilters), listProductApplications()]); this.dashboard = dashboard.data || {}; this.products = products.data || []; this.applications = applications.data || [] } catch (error) { this.notifyError(error) } finally { this.loading = false } },
+    async loadProducts () { this.loading = true; try { const { data } = await listGovernedProducts(this.productFilters); this.products = data || [] } catch (error) { this.notifyError(error) } finally { this.loading = false } },
+    async loadApplications () { this.loading = true; try { const { data } = await listProductApplications({ status: this.applicationStatus || undefined }); this.applications = data || [] } catch (error) { this.notifyError(error) } finally { this.loading = false } },
+    async openProduct (item) { try { const { data } = await getGovernedProduct(item.uid); this.detail = data; this.detailTab = 0; this.detailVisible = true } catch (error) { this.notifyError(error) } },
+    openApplicationDialog () { this.applicationForm = emptyApplication(); this.applicationVisible = true },
+    async registerProduct () { await this.save(async () => registerGovernedProduct(this.registerForm), '数据产品已纳入治理', () => { this.registerVisible = false }) },
+    async createApplication () { const form = this.applicationForm; const payload = { title: form.title, source_type: form.source_type, source_ref: { reference: form.sourceValue }, business_domain_uid: form.business_domain_uid, purpose: form.purpose, requested_fields: form.requested_fields }; await this.save(async () => { const result = await createProductApplication(payload); if (form.workflow_uid) await submitProductApplication(result.data.uid, { workflow_uid: form.workflow_uid }, result.data.current_version); return result }, '产品申请已保存', () => { this.applicationVisible = false }) },
+    async submitApplication (item) { const workflowUid = window.prompt('请输入已发布的审批流程 UID'); if (!workflowUid) return; await this.save(() => submitProductApplication(item.uid, { workflow_uid: workflowUid }, item.current_version), '申请已进入统一工作中心') },
+    async reconcileApplication (item) { await this.save(() => reconcileProductApplication(item.uid, item.current_version), '审批结果已同步') },
+    async fulfillApplication (item) { const productUid = window.prompt('请输入本次履约关联的治理产品 UID'); if (!productUid) return; await this.save(() => fulfillProductApplication(item.uid, productUid, item.current_version), '申请履约证据已记录') },
+    openContractDialog () { this.contractVisible = true },
+    contractPayload () { const fields = this.contractForm.fields.split('\n').filter(Boolean).map(line => { const parts = line.split(',').map(value => value.trim()); return { name: parts[0], type: parts[1], nullable: parts[2] === 'true' } }); return { schema: { fields }, delivery: { mode: this.contractForm.deliveryMode, format: this.contractForm.deliveryFormat }, quality_terms: { minimum_score: this.detail.quality_target }, sla_terms: { freshness_hours: this.detail.sla.freshness_hours, availability_target: this.detail.sla.availability_target }, usage_terms: { purpose: this.contractForm.purpose, retention_days: this.contractForm.retentionDays }, compatibility_mode: this.contractForm.compatibilityMode, change_reason: this.contractForm.changeReason } },
+    async createContract () { await this.save(() => createProductContract(this.detail.uid, this.contractPayload()), '合同草案已创建', () => { this.contractVisible = false }, true) },
+    async publishContract () { await this.save(() => publishProductContract(this.detail.contract.uid, this.detail.contract.current_version), '数据合同已发布', null, true) },
+    async issueCertificate () { const form = this.certificateForm; await this.save(() => issueProductCertificate(this.detail.uid, { approval_task_uid: form.approval_task_uid, evidence: { quality_run_uid: form.quality_run_uid, lineage_uids: form.lineage_uids, rule_version_uids: form.rule_version_uids, workflow_run_uids: form.workflow_run_uids } }), '产品合格证已生成', () => { this.certificateVisible = false }, true) },
+    async createFeedback () { await this.save(() => createProductFeedback(this.detail.uid, this.feedbackForm), '反馈已进入改进闭环', () => { this.feedbackVisible = false }, true) },
+    feedbackAction (status) { return { open: { text: '分诊', action: 'triage' }, triaged: { text: '开始处理', action: 'start' }, in_progress: { text: '提交解决', action: 'resolve' }, resolved: { text: '确认关闭', action: 'close' }, closed: { text: '重开', action: 'reopen' } }[status] || {} },
+    canAdvanceFeedback (feedback) { const userUid = (this.$store.state.user.userInfo || {}).id; return this.canOperate && (this.canGovernDetail || feedback.assignee_uid === userUid || feedback.created_by === userUid) && Boolean(this.feedbackAction(feedback.status).action) },
+    async advanceFeedback (feedback) { const next = this.feedbackAction(feedback.status); if (!next.action) return; const note = window.prompt(`请填写“${next.text}”的处理说明`); if (!note) return; const payload = { action: next.action, note }; if (next.action === 'triage') { const assigneeUid = window.prompt('请输入改进责任人 UID'); if (!assigneeUid) return; payload.assignee_uid = assigneeUid } if (next.action === 'resolve') { const evidence = window.prompt('请输入解决证据引用'); if (!evidence) return; payload.evidence_refs = [evidence] } await this.save(() => transitionProductFeedback(feedback.uid, payload, feedback.current_version), `反馈已${next.text}`, null, true) },
+    async transitionProduct (action) { const reason = window.prompt('请填写本次生命周期变更原因'); if (!reason) return; await this.save(() => transitionGovernedProduct(this.detail.uid, { action, reason }, this.detail.current_version), '产品生命周期已更新', null, true) },
+    async save (operation, message, close, reloadDetail = false) { this.saving = true; try { await operation(); this.$snackbar.success(message); if (close) close(); if (reloadDetail && this.detail) await this.openProduct(this.detail); await this.refreshAll() } catch (error) { this.notifyError(error) } finally { this.saving = false } },
+    notifyError (error) { this.$snackbar.error(error && (error.message || error.msg) ? (error.message || error.msg) : error) },
+    lifecycleActions (status) { return { draft: [{ text: '提交评审', value: 'submit_review' }, { text: '激活', value: 'activate' }, { text: '退役', value: 'retire' }], in_review: [{ text: '激活', value: 'activate' }, { text: '退役', value: 'retire' }], active: [{ text: '暂停', value: 'suspend' }, { text: '退役', value: 'retire' }], suspended: [{ text: '重新激活', value: 'reactivate' }, { text: '退役', value: 'retire' }] }[status] || [] },
+    typeLabel (value) { return { database: '数据库', api: 'API', file: '文件', data_product: '数据产品' }[value] || value },
+    supportLabel (value) { return { best_effort: '尽力支持', business_hours: '工作时间', '24x7': '7×24 小时' }[value] || value },
+    statusLabel (value) { return { draft: '草稿', in_review: '评审中', active: '已激活', suspended: '已暂停', retired: '已退役', pending_approval: '审批中', approved: '已批准', rejected: '已驳回', fulfilled: '已履约', terminated: '已终止', published: '已发布', superseded: '已替代', qualified: '合格', unqualified: '不合格', open: '待分诊', triaged: '已分诊', in_progress: '改进中', resolved: '待确认', closed: '已关闭' }[value] || value },
+    statusColor (value) { return { active: 'success', qualified: 'success', approved: 'success', draft: 'blue-grey', in_review: 'primary', pending_approval: 'primary', suspended: 'warning', rejected: 'error', retired: 'grey', terminated: 'grey', unqualified: 'error', open: 'orange', in_progress: 'primary' }[value] || 'blue-grey' },
+    eventLabel (action) { return String(action || '').replace(/_/g, ' ') },
+    formatTime (value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-' }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.product-governance { min-height: 100%; background: #f4f7fb; }
+.hero { display: flex; align-items: center; justify-content: space-between; color: white; border-radius: 16px; background: linear-gradient(125deg, #102a43 0%, #1e40af 55%, #0f766e 100%); box-shadow: 0 16px 36px rgba(30, 64, 175, .2); }
+.eyebrow { font-size: 11px; letter-spacing: .18em; opacity: .72; }
+.hero-actions { display: flex; align-items: center; }
+.metric-card { border-radius: 12px; border-top: 3px solid #dbeafe !important; }
+.content-card { border-radius: 14px; overflow: hidden; }
+.toolbar { display: flex; gap: 12px; align-items: center; }
+.toolbar .v-input { max-width: 220px; }
+.boundary-card { height: 100%; border-radius: 12px; line-height: 1.7; }
+.detail-title { display: flex; justify-content: space-between; align-items: center; }
+.feedback-action { gap: 4px; align-items: flex-end; }
+@media (max-width: 800px) { .hero { align-items: flex-start; flex-direction: column; gap: 18px; } .hero-actions { flex-wrap: wrap; } .toolbar { align-items: stretch; flex-direction: column; } .toolbar .v-input { max-width: none; } }
+</style>

+ 225 - 0
migrations/versions/20260802_440_product_governance.py

@@ -0,0 +1,225 @@
+"""Add governed data products, applications, contracts and certificates."""
+
+from alembic import op
+
+revision = "20260802_440"
+down_revision = "20260802_430"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.governed_data_products (
+            uid UUID PRIMARY KEY,
+            legacy_product_id INTEGER NOT NULL UNIQUE
+                REFERENCES public.data_products(id) ON DELETE RESTRICT,
+            product_code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            product_type VARCHAR(30) NOT NULL CHECK (
+                product_type IN ('database','api','file','data_product')
+            ),
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            business_domain_uid UUID NOT NULL,
+            description VARCHAR(2000) NOT NULL,
+            quality_target NUMERIC(6,2) NOT NULL CHECK (
+                quality_target >= 0 AND quality_target <= 100
+            ),
+            sla JSONB NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','in_review','active','suspended','retired')
+            ),
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            retired_at TIMESTAMPTZ,
+            CHECK (jsonb_typeof(sla) = 'object')
+        );
+        CREATE INDEX idx_governed_product_domain_status
+            ON public.governed_data_products(
+                business_domain_uid, status, updated_at DESC
+            );
+        CREATE INDEX idx_governed_product_owner_status
+            ON public.governed_data_products(owner_uid, status, updated_at DESC);
+
+        CREATE TABLE public.data_product_governance_events (
+            uid UUID PRIMARY KEY,
+            product_uid UUID NOT NULL
+                REFERENCES public.governed_data_products(uid) ON DELETE CASCADE,
+            product_version INTEGER NOT NULL CHECK (product_version > 0),
+            action VARCHAR(50) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(payload) = 'object')
+        );
+        CREATE INDEX idx_product_governance_timeline
+            ON public.data_product_governance_events(product_uid, created_at, uid);
+
+        CREATE TABLE public.data_product_applications (
+            uid UUID PRIMARY KEY,
+            application_code VARCHAR(40) NOT NULL UNIQUE,
+            title VARCHAR(300) NOT NULL,
+            source_type VARCHAR(30) NOT NULL CHECK (
+                source_type IN ('database','api','file','data_product')
+            ),
+            source_ref JSONB NOT NULL,
+            business_domain_uid UUID NOT NULL,
+            purpose VARCHAR(1000) NOT NULL,
+            requested_fields JSONB NOT NULL,
+            status VARCHAR(30) NOT NULL CHECK (
+                status IN (
+                    'draft','pending_approval','approved','rejected',
+                    'fulfilled','cancelled'
+                )
+            ),
+            approval_task_uid UUID REFERENCES public.governance_tasks(uid),
+            product_uid UUID REFERENCES public.governed_data_products(uid),
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(source_ref) = 'object'),
+            CHECK (jsonb_typeof(requested_fields) = 'array')
+        );
+        CREATE INDEX idx_product_application_worklist
+            ON public.data_product_applications(
+                created_by, status, updated_at DESC
+            );
+        CREATE INDEX idx_product_application_domain
+            ON public.data_product_applications(
+                business_domain_uid, status, updated_at DESC
+            );
+
+        CREATE TABLE public.data_product_application_events (
+            uid UUID PRIMARY KEY,
+            application_uid UUID NOT NULL
+                REFERENCES public.data_product_applications(uid) ON DELETE CASCADE,
+            application_version INTEGER NOT NULL CHECK (application_version > 0),
+            action VARCHAR(50) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(payload) = 'object')
+        );
+        CREATE INDEX idx_product_application_timeline
+            ON public.data_product_application_events(
+                application_uid, created_at, uid
+            );
+
+        CREATE TABLE public.data_product_contracts (
+            uid UUID PRIMARY KEY,
+            product_uid UUID NOT NULL UNIQUE
+                REFERENCES public.governed_data_products(uid) ON DELETE RESTRICT,
+            contract_code VARCHAR(180) NOT NULL UNIQUE,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','active','terminated')
+            ),
+            current_version INTEGER NOT NULL CHECK (current_version > 0),
+            active_version_uid UUID,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            terminated_at TIMESTAMPTZ,
+            termination_reason VARCHAR(1000)
+        );
+
+        CREATE TABLE public.data_product_contract_versions (
+            uid UUID PRIMARY KEY,
+            contract_uid UUID NOT NULL
+                REFERENCES public.data_product_contracts(uid) ON DELETE RESTRICT,
+            version INTEGER NOT NULL CHECK (version > 0),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','superseded')
+            ),
+            definition JSONB NOT NULL,
+            content_hash CHAR(64) NOT NULL,
+            compatibility JSONB NOT NULL,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            published_by UUID REFERENCES public.users(id),
+            published_at TIMESTAMPTZ,
+            UNIQUE (contract_uid, version),
+            UNIQUE (contract_uid, content_hash),
+            CHECK (jsonb_typeof(definition) = 'object'),
+            CHECK (jsonb_typeof(compatibility) = 'object')
+        );
+        ALTER TABLE public.data_product_contracts
+            ADD CONSTRAINT data_product_contract_active_version_fk
+            FOREIGN KEY (active_version_uid)
+            REFERENCES public.data_product_contract_versions(uid);
+        CREATE UNIQUE INDEX uq_data_product_contract_published_version
+            ON public.data_product_contract_versions(contract_uid)
+            WHERE status = 'published';
+
+        CREATE TABLE public.data_product_certificates (
+            uid UUID PRIMARY KEY,
+            certificate_code VARCHAR(50) NOT NULL UNIQUE,
+            product_uid UUID NOT NULL
+                REFERENCES public.governed_data_products(uid) ON DELETE RESTRICT,
+            contract_uid UUID NOT NULL
+                REFERENCES public.data_product_contracts(uid) ON DELETE RESTRICT,
+            contract_version INTEGER NOT NULL CHECK (contract_version > 0),
+            approval_task_uid UUID NOT NULL REFERENCES public.governance_tasks(uid),
+            evidence_snapshot JSONB NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('qualified','unqualified')
+            ),
+            content_hash CHAR(64) NOT NULL,
+            issued_by UUID NOT NULL REFERENCES public.users(id),
+            issued_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (product_uid, content_hash),
+            CHECK (jsonb_typeof(evidence_snapshot) = 'object')
+        );
+        CREATE INDEX idx_product_certificate_latest
+            ON public.data_product_certificates(product_uid, issued_at DESC);
+
+        CREATE TABLE public.data_product_feedback (
+            uid UUID PRIMARY KEY,
+            product_uid UUID NOT NULL
+                REFERENCES public.governed_data_products(uid) ON DELETE CASCADE,
+            category VARCHAR(30) NOT NULL CHECK (
+                category IN (
+                    'quality','freshness','usability',
+                    'documentation','service'
+                )
+            ),
+            rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
+            summary VARCHAR(300) NOT NULL,
+            details VARCHAR(2000) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('open','triaged','in_progress','resolved','closed')
+            ),
+            assignee_uid UUID REFERENCES public.users(id),
+            resolution VARCHAR(2000),
+            evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            closed_at TIMESTAMPTZ,
+            CHECK (jsonb_typeof(evidence_refs) = 'array')
+        );
+        CREATE INDEX idx_product_feedback_worklist
+            ON public.data_product_feedback(
+                product_uid, status, updated_at DESC
+            );
+        CREATE INDEX idx_product_feedback_assignee
+            ON public.data_product_feedback(
+                assignee_uid, status, updated_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "governed product contracts, certificates and feedback are retained; "
+        "downgrade requires an approved archival migration"
+    )

+ 520 - 0
tests/core/data_service/test_product_governance.py

@@ -0,0 +1,520 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from datetime import UTC, datetime
+
+import pytest
+
+from app.core.data_service.product_governance import ProductGovernanceService
+
+ACTOR = "01900000-0000-7000-8000-000000008001"
+OWNER = "01900000-0000-7000-8000-000000008002"
+REVIEWER = "01900000-0000-7000-8000-000000008003"
+SECOND_DOMAIN = "01900000-0000-7000-8000-000000008101"
+WORKFLOW_UID = "01900000-0000-7000-8000-000000008201"
+QUALITY_RUN_UID = "01900000-0000-7000-8000-000000008301"
+LINEAGE_UID = "01900000-0000-7000-8000-000000008302"
+RULE_VERSION_UID = "01900000-0000-7000-8000-000000008303"
+WORKFLOW_RUN_UID = "01900000-0000-7000-8000-000000008304"
+NOW = datetime(2026, 8, 2, 10, 0, tzinfo=UTC)
+
+
+class MemoryProductGovernanceRepository:
+    def __init__(self):
+        self.users = {ACTOR, OWNER, REVIEWER}
+        self.legacy_products = {42}
+        self.products = {}
+        self.applications = {}
+        self.contracts = {}
+        self.contract_versions = {}
+        self.certificates = {}
+        self.feedbacks = {}
+        self.events = {}
+
+    def users_available(self, user_uids):
+        return set(user_uids) & self.users
+
+    def legacy_product_exists(self, product_id):
+        return product_id in self.legacy_products
+
+    def create_product(self, record):
+        self.products[record["uid"]] = deepcopy(record)
+        self.events[record["uid"]] = []
+        return deepcopy(record)
+
+    def get_product(self, uid):
+        value = self.products.get(uid)
+        return deepcopy(value) if value else None
+
+    def list_products(self, **filters):
+        values = list(self.products.values())
+        for key in ("status", "product_type", "business_domain_uid", "owner_uid"):
+            if filters.get(key):
+                values = [item for item in values if item.get(key) == filters[key]]
+        return [deepcopy(item) for item in values]
+
+    def update_product(self, record, expected_version, action, actor_uid, payload):
+        current = self.products[record["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("product version conflict")
+        saved = {**deepcopy(record), "current_version": expected_version + 1}
+        self.products[record["uid"]] = saved
+        self.add_event(record["uid"], action, actor_uid, payload, saved["current_version"])
+        return deepcopy(saved)
+
+    def add_event(self, product_uid, action, actor_uid, payload, version=1):
+        self.events.setdefault(product_uid, []).append(
+            {
+                "action": action,
+                "actor_uid": actor_uid,
+                "payload": deepcopy(payload),
+                "product_version": version,
+                "created_at": NOW.isoformat(),
+            }
+        )
+
+    def create_application(self, record):
+        self.applications[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def get_application(self, uid):
+        value = self.applications.get(uid)
+        return deepcopy(value) if value else None
+
+    def list_applications(self, **filters):
+        values = list(self.applications.values())
+        if filters.get("status"):
+            values = [item for item in values if item["status"] == filters["status"]]
+        return [deepcopy(item) for item in values]
+
+    def update_application(self, record, expected_version, action, actor_uid, payload):
+        current = self.applications[record["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("application version conflict")
+        saved = {**deepcopy(record), "current_version": expected_version + 1}
+        self.applications[record["uid"]] = saved
+        if saved.get("product_uid"):
+            self.add_event(saved["product_uid"], action, actor_uid, payload)
+        return deepcopy(saved)
+
+    def create_contract(self, contract, version):
+        self.contracts[contract["uid"]] = deepcopy(contract)
+        self.contract_versions[version["uid"]] = deepcopy(version)
+        return deepcopy(contract)
+
+    def get_contract(self, uid):
+        value = self.contracts.get(uid)
+        return deepcopy(value) if value else None
+
+    def contract_for_product(self, product_uid):
+        return next(
+            (
+                deepcopy(item)
+                for item in self.contracts.values()
+                if item["product_uid"] == product_uid
+            ),
+            None,
+        )
+
+    def contract_version(self, contract_uid, version):
+        return next(
+            (
+                deepcopy(item)
+                for item in self.contract_versions.values()
+                if item["contract_uid"] == contract_uid and item["version"] == version
+            ),
+            None,
+        )
+
+    def active_contract_version(self, contract_uid):
+        contract = self.contracts[contract_uid]
+        uid = contract.get("active_version_uid")
+        return deepcopy(self.contract_versions[uid]) if uid else None
+
+    def revise_contract(self, contract, version, expected_version):
+        current = self.contracts[contract["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("contract version conflict")
+        self.contracts[contract["uid"]] = deepcopy(contract)
+        self.contract_versions[version["uid"]] = deepcopy(version)
+        return deepcopy(contract)
+
+    def publish_contract(self, contract, version, expected_version):
+        current = self.contracts[contract["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("contract version conflict")
+        active_uid = current.get("active_version_uid")
+        if active_uid:
+            self.contract_versions[active_uid]["status"] = "superseded"
+        self.contracts[contract["uid"]] = deepcopy(contract)
+        self.contract_versions[version["uid"]] = deepcopy(version)
+        return deepcopy(contract)
+
+    def terminate_contract(self, contract, expected_version):
+        current = self.contracts[contract["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("contract version conflict")
+        self.contracts[contract["uid"]] = deepcopy(contract)
+        return deepcopy(contract)
+
+    def evidence_snapshot(self, references):
+        assert references == {
+            "quality_run_uid": QUALITY_RUN_UID,
+            "lineage_uids": [LINEAGE_UID],
+            "rule_version_uids": [RULE_VERSION_UID],
+            "workflow_run_uids": [WORKFLOW_RUN_UID],
+        }
+        return {
+            "quality": {
+                "uid": QUALITY_RUN_UID,
+                "status": "success",
+                "score": 96.5,
+                "batch_key": "quality-batch-2",
+            },
+            "sla_events": [
+                {"sla_type": "quality_score", "status": "met"},
+                {"sla_type": "freshness", "status": "met"},
+            ],
+            "lineage": [{"uid": LINEAGE_UID, "parse_status": "resolved"}],
+            "rule_versions": [
+                {"uid": RULE_VERSION_UID, "version": 3, "status": "published"}
+            ],
+            "workflow_runs": [{"uid": WORKFLOW_RUN_UID, "status": "success"}],
+        }
+
+    def create_certificate(self, record):
+        self.certificates[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def latest_certificate(self, product_uid):
+        values = [
+            item for item in self.certificates.values() if item["product_uid"] == product_uid
+        ]
+        return deepcopy(values[-1]) if values else None
+
+    def create_feedback(self, record):
+        self.feedbacks[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def get_feedback(self, uid):
+        value = self.feedbacks.get(uid)
+        return deepcopy(value) if value else None
+
+    def update_feedback(self, record, expected_version, action, actor_uid, payload):
+        current = self.feedbacks[record["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("feedback version conflict")
+        saved = {**deepcopy(record), "current_version": expected_version + 1}
+        self.feedbacks[record["uid"]] = saved
+        self.add_event(saved["product_uid"], action, actor_uid, payload)
+        return deepcopy(saved)
+
+    def product_detail(self, uid):
+        product = self.get_product(uid)
+        return {
+            **product,
+            "contract": self.contract_for_product(uid),
+            "certificates": [
+                deepcopy(item)
+                for item in self.certificates.values()
+                if item["product_uid"] == uid
+            ],
+            "feedback": [
+                deepcopy(item)
+                for item in self.feedbacks.values()
+                if item["product_uid"] == uid
+            ],
+            "timeline": deepcopy(self.events.get(uid, [])),
+        }
+
+    def dashboard(self):
+        return {
+            "product_count": len(self.products),
+            "active_count": sum(item["status"] == "active" for item in self.products.values()),
+            "application_pending_count": sum(
+                item["status"] == "pending_approval" for item in self.applications.values()
+            ),
+            "open_feedback_count": sum(
+                item["status"] not in {"closed"} for item in self.feedbacks.values()
+            ),
+        }
+
+
+class FakeApprovalGateway:
+    def __init__(self):
+        self.tasks = {}
+        self.sequence = 1
+
+    def create_product_task(self, application, workflow_uid, actor_uid):
+        uid = f"01900000-0000-7000-8000-{8400 + self.sequence:012d}"
+        self.sequence += 1
+        task = {
+            "uid": uid,
+            "status": "pending",
+            "workflow_uid": workflow_uid,
+            "source_uid": application["uid"],
+            "subject_type": "data_product",
+        }
+        self.tasks[uid] = task
+        return deepcopy(task)
+
+    def get_task(self, uid):
+        return deepcopy(self.tasks.get(uid))
+
+
+@pytest.fixture()
+def service():
+    repository = MemoryProductGovernanceRepository()
+    gateway = FakeApprovalGateway()
+    uids = (
+        f"01900000-0000-7000-8000-{number:012d}"
+        for number in range(8100, 8400)
+    )
+    center = ProductGovernanceService(
+        repository,
+        approval_gateway=gateway,
+        uid_factory=lambda: next(uids),
+        now_factory=lambda: NOW,
+    )
+    return center, repository, gateway
+
+
+def product_payload():
+    return {
+        "legacy_product_id": 42,
+        "product_code": "SECOND_DOMAIN_DEVICE_HEALTH",
+        "name": "第二业务域设备健康数据产品",
+        "product_type": "database",
+        "owner_uid": OWNER,
+        "business_domain_uid": SECOND_DOMAIN,
+        "description": "Governed product for the second business domain",
+        "quality_target": 95,
+        "sla": {
+            "availability_target": 99.9,
+            "freshness_hours": 4,
+            "support_tier": "business_hours",
+        },
+    }
+
+
+def initial_contract():
+    return {
+        "schema": {
+            "fields": [
+                {"name": "device_id", "type": "string", "nullable": False},
+                {"name": "health_score", "type": "number", "nullable": False},
+            ]
+        },
+        "delivery": {"mode": "table", "format": "parquet"},
+        "quality_terms": {"minimum_score": 95},
+        "sla_terms": {"freshness_hours": 4, "availability_target": 99.9},
+        "usage_terms": {"purpose": "operations", "retention_days": 365},
+        "compatibility_mode": "backward",
+        "change_reason": "initial contract",
+    }
+
+
+def test_second_domain_product_has_owner_lifecycle_quality_and_sla(service):
+    center, repository, _gateway = service
+    product = center.register_product(product_payload(), actor_uid=ACTOR)
+    review = center.transition_product(
+        product["uid"],
+        {"action": "submit_review", "reason": "ready for governance"},
+        expected_version=1,
+        actor_uid=OWNER,
+    )
+
+    assert product["business_domain_uid"] == SECOND_DOMAIN
+    assert product["owner_uid"] == OWNER
+    assert product["quality_target"] == 95
+    assert product["sla"]["freshness_hours"] == 4
+    assert review["status"] == "in_review"
+    assert repository.events[product["uid"]][-1]["action"] == "product_submitted"
+
+
+def test_four_application_sources_use_work_center_multi_level_approval(service):
+    center, _repository, gateway = service
+    applications = []
+    for source_type in ("database", "api", "file", "data_product"):
+        draft = center.create_application(
+            {
+                "title": f"Request {source_type}",
+                "source_type": source_type,
+                "source_ref": {"key": f"second-domain/{source_type}"},
+                "business_domain_uid": SECOND_DOMAIN,
+                "purpose": "设备运行治理",
+                "requested_fields": ["device_id", "health_score"],
+            },
+            actor_uid=ACTOR,
+        )
+        submitted = center.submit_application(
+            draft["uid"],
+            {"workflow_uid": WORKFLOW_UID},
+            expected_version=1,
+            actor_uid=ACTOR,
+        )
+        applications.append(submitted)
+
+    assert {item["source_type"] for item in applications} == {
+        "database",
+        "api",
+        "file",
+        "data_product",
+    }
+    assert all(item["status"] == "pending_approval" for item in applications)
+    assert all(item["approval_task_uid"] for item in applications)
+    first = applications[0]
+    gateway.tasks[first["approval_task_uid"]]["status"] = "approved"
+    approved = center.reconcile_application(
+        first["uid"], expected_version=2, actor_uid=REVIEWER
+    )
+    assert approved["status"] == "approved"
+
+
+def test_contract_versions_check_compatibility_publish_and_terminate(service):
+    center, _repository, _gateway = service
+    product = center.register_product(product_payload(), actor_uid=ACTOR)
+    contract = center.create_contract(product["uid"], initial_contract(), actor_uid=OWNER)
+    published = center.publish_contract(
+        contract["uid"], expected_version=1, actor_uid=OWNER
+    )
+    candidate = initial_contract()
+    candidate["schema"]["fields"].append(
+        {"name": "observed_at", "type": "datetime", "nullable": True}
+    )
+    candidate["change_reason"] = "add optional observation time"
+    compatibility = center.check_contract_compatibility(contract["uid"], candidate)
+    revised = center.revise_contract(
+        contract["uid"], candidate, expected_version=1, actor_uid=OWNER
+    )
+    republished = center.publish_contract(
+        contract["uid"], expected_version=2, actor_uid=OWNER
+    )
+
+    breaking = deepcopy(candidate)
+    breaking["schema"]["fields"] = [breaking["schema"]["fields"][1]]
+    breaking["change_reason"] = "remove identity"
+    broken = center.check_contract_compatibility(contract["uid"], breaking)
+    terminated = center.terminate_contract(
+        contract["uid"],
+        {"reason": "product retired", "evidence_refs": ["change://approved-1"]},
+        expected_version=2,
+        actor_uid=OWNER,
+    )
+
+    assert published["status"] == "active"
+    assert compatibility["compatible"] is True
+    assert revised["current_version"] == 2
+    assert republished["active_version_uid"] == revised["latest_version"]["uid"]
+    assert broken["compatible"] is False
+    assert any("device_id" in item for item in broken["breaking_changes"])
+    assert terminated["status"] == "terminated"
+
+
+def test_certificate_snapshots_quality_lineage_rules_runs_and_approval(service):
+    center, repository, gateway = service
+    product = center.register_product(product_payload(), actor_uid=ACTOR)
+    contract = center.create_contract(product["uid"], initial_contract(), actor_uid=OWNER)
+    center.publish_contract(contract["uid"], expected_version=1, actor_uid=OWNER)
+    application = center.create_application(
+        {
+            "title": "Use governed device health product",
+            "source_type": "data_product",
+            "source_ref": {"product_uid": product["uid"]},
+            "business_domain_uid": SECOND_DOMAIN,
+            "purpose": "设备运行治理",
+            "requested_fields": ["device_id"],
+        },
+        actor_uid=ACTOR,
+    )
+    submitted = center.submit_application(
+        application["uid"],
+        {"workflow_uid": WORKFLOW_UID},
+        expected_version=1,
+        actor_uid=ACTOR,
+    )
+    gateway.tasks[submitted["approval_task_uid"]]["status"] = "approved"
+    center.reconcile_application(submitted["uid"], expected_version=2, actor_uid=REVIEWER)
+    certificate = center.generate_certificate(
+        product["uid"],
+        {
+            "approval_task_uid": submitted["approval_task_uid"],
+            "evidence": {
+                "quality_run_uid": QUALITY_RUN_UID,
+                "lineage_uids": [LINEAGE_UID],
+                "rule_version_uids": [RULE_VERSION_UID],
+                "workflow_run_uids": [WORKFLOW_RUN_UID],
+            },
+        },
+        actor_uid=OWNER,
+    )
+    activated = center.transition_product(
+        product["uid"],
+        {"action": "activate", "reason": "qualified certificate issued"},
+        expected_version=1,
+        actor_uid=OWNER,
+    )
+
+    assert certificate["status"] == "qualified"
+    assert certificate["evidence_snapshot"]["quality"]["score"] == 96.5
+    assert certificate["evidence_snapshot"]["lineage"][0]["uid"] == LINEAGE_UID
+    assert certificate["evidence_snapshot"]["rule_versions"][0]["version"] == 3
+    assert certificate["evidence_snapshot"]["workflow_runs"][0]["status"] == "success"
+    assert len(certificate["content_hash"]) == 64
+    assert activated["status"] == "active"
+    assert repository.latest_certificate(product["uid"])["uid"] == certificate["uid"]
+
+
+def test_feedback_improvement_loop_is_versioned_and_reopenable(service):
+    center, repository, _gateway = service
+    product = center.register_product(product_payload(), actor_uid=ACTOR)
+    feedback = center.create_feedback(
+        product["uid"],
+        {
+            "category": "quality",
+            "rating": 2,
+            "summary": "健康分数存在延迟",
+            "details": "第二业务域早班数据延迟四小时以上",
+        },
+        actor_uid=ACTOR,
+    )
+    triaged = center.transition_feedback(
+        feedback["uid"],
+        {"action": "triage", "assignee_uid": OWNER, "note": "纳入改进"},
+        expected_version=1,
+        actor_uid=OWNER,
+    )
+    started = center.transition_feedback(
+        feedback["uid"],
+        {"action": "start", "note": "调整采集窗口"},
+        expected_version=2,
+        actor_uid=OWNER,
+    )
+    resolved = center.transition_feedback(
+        feedback["uid"],
+        {"action": "resolve", "note": "完成增量调度修复", "evidence_refs": ["run://fixed-1"]},
+        expected_version=3,
+        actor_uid=OWNER,
+    )
+    closed = center.transition_feedback(
+        feedback["uid"],
+        {"action": "close", "note": "用户确认"},
+        expected_version=4,
+        actor_uid=ACTOR,
+    )
+    reopened = center.transition_feedback(
+        feedback["uid"],
+        {"action": "reopen", "note": "次日再次延迟"},
+        expected_version=5,
+        actor_uid=ACTOR,
+    )
+
+    assert [triaged["status"], started["status"], resolved["status"]] == [
+        "triaged",
+        "in_progress",
+        "resolved",
+    ]
+    assert closed["status"] == "closed"
+    assert reopened["status"] == "open"
+    assert repository.events[product["uid"]][-1]["action"] == "feedback_reopened"
+    assert center.dashboard()["open_feedback_count"] == 1

+ 346 - 0
tests/integration/test_product_governance_postgres.py

@@ -0,0 +1,346 @@
+from __future__ import annotations
+
+import os
+import uuid
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import Session
+
+from app.core.data_service.product_governance import ProductGovernanceService
+from app.core.data_service.product_governance_repository import (
+    SqlAlchemyProductGovernanceRepository,
+    WorkCenterProductApprovalGateway,
+)
+from app.core.governance.work_center import UnifiedWorkCenterService
+from app.core.governance.work_center_repository import SqlAlchemyWorkCenterRepository
+
+pytestmark = pytest.mark.integration
+
+
+def _uid():
+    return str(uuid.uuid4())
+
+
+def _seed_certificate_evidence(session, owner_uid, domain_uid):
+    now = datetime.now(UTC)
+    ids = {name: _uid() for name in (
+        "source", "plan", "metadata_run", "asset", "lineage", "template",
+        "template_version", "quality_run", "sla_freshness", "sla_quality",
+        "rule", "rule_version", "dataflow", "workflow_version", "workflow_run",
+    )}
+    session.execute(text("""
+        INSERT INTO public.ingestion_sources (
+            uid, source_type, name, config, permission_scope, status, created_by
+        ) VALUES (
+            CAST(:source AS uuid), 'database', 'WP08 second-domain source',
+            '{}'::jsonb, '{}'::jsonb, 'active', :owner
+        )
+    """), {**ids, "owner": owner_uid})
+    session.execute(text("""
+        INSERT INTO public.active_metadata_plans (
+            uid, source_uid, name, source_kind, schedule_type, discovery_mode,
+            scope, cursor_state, owner_uid, enabled, current_version, created_by
+        ) VALUES (
+            CAST(:plan AS uuid), CAST(:source AS uuid), 'WP08 metadata plan',
+            'database', 'manual', 'snapshot', '{}'::jsonb, '{}'::jsonb,
+            CAST(:owner AS uuid), TRUE, 1, CAST(:owner AS uuid)
+        )
+    """), {**ids, "owner": owner_uid})
+    session.execute(text("""
+        INSERT INTO public.active_metadata_runs (
+            uid, plan_uid, batch_key, status, attempt_count, cursor_before,
+            cursor_after, snapshot_hash, statistics, actor_uid, started_at, finished_at
+        ) VALUES (
+            CAST(:metadata_run AS uuid), CAST(:plan AS uuid), 'wp08-batch',
+            'completed', 1, '{}'::jsonb, '{}'::jsonb, :hash,
+            '{}'::jsonb, CAST(:owner AS uuid), :started_at, :finished_at
+        )
+    """), {**ids, "owner": owner_uid, "hash": "a" * 64, "started_at": now - timedelta(minutes=5), "finished_at": now})
+    session.execute(text("""
+        INSERT INTO public.active_metadata_assets (
+            uid, source_uid, asset_key, namespace, name, asset_type,
+            lifecycle_status, current_version, content_hash, snapshot,
+            health, last_run_uid
+        ) VALUES (
+            CAST(:asset AS uuid), CAST(:source AS uuid), :asset_key,
+            'equipment_ops', 'device_health', 'table', 'active', 1, :hash,
+            CAST(:snapshot AS jsonb), '{}'::jsonb, CAST(:metadata_run AS uuid)
+        )
+    """), {**ids, "asset_key": f"{ids['source']}:equipment_ops.device_health", "hash": "b" * 64, "snapshot": f'{{"business_domain_uid":"{domain_uid}"}}'})
+    session.execute(text("""
+        INSERT INTO public.active_metadata_lineage (
+            uid, run_uid, parse_status, source_asset, source_field,
+            target_asset, target_field, relation_type, evidence
+        ) VALUES (
+            CAST(:lineage AS uuid), CAST(:metadata_run AS uuid), 'resolved',
+            'iot.device_events', 'device_id', 'equipment_ops.device_health',
+            'device_id', 'derived_from', '{}'::jsonb
+        )
+    """), ids)
+    session.execute(text("""
+        INSERT INTO public.quality_templates (
+            uid, code, name, owner_uid, status, current_version, created_by
+        ) VALUES (
+            CAST(:template AS uuid), :code, 'WP08 quality template',
+            CAST(:owner AS uuid), 'published', 1, CAST(:owner AS uuid)
+        )
+    """), {**ids, "owner": owner_uid, "code": f"WP08_{ids['template'][:8].upper()}"})
+    session.execute(text("""
+        INSERT INTO public.quality_template_versions (
+            uid, template_uid, version, status, definition, content_hash,
+            created_by, published_by, published_at
+        ) VALUES (
+            CAST(:template_version AS uuid), CAST(:template AS uuid), 1,
+            'published', '{}'::jsonb, :hash, CAST(:owner AS uuid),
+            CAST(:owner AS uuid), :now
+        )
+    """), {**ids, "owner": owner_uid, "hash": "c" * 64, "now": now})
+    session.execute(text("""
+        UPDATE public.quality_templates
+        SET active_version_uid = CAST(:template_version AS uuid)
+        WHERE uid = CAST(:template AS uuid)
+    """), ids)
+    session.execute(text("""
+        INSERT INTO public.quality_profile_runs (
+            uid, template_uid, template_version_uid, template_hash, asset_uid,
+            source_uid, business_domain_uid, batch_key, status, row_count,
+            score, source_observed_at, comparison, profile, field_bindings,
+            finding_count, created_by
+        ) VALUES (
+            CAST(:quality_run AS uuid), CAST(:template AS uuid),
+            CAST(:template_version AS uuid), :hash, CAST(:asset AS uuid),
+            CAST(:source AS uuid), :domain, 'wp08-product-batch', 'success', 2,
+            98.50, :now, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, 0,
+            CAST(:owner AS uuid)
+        )
+    """), {**ids, "owner": owner_uid, "domain": domain_uid, "hash": "c" * 64, "now": now})
+    for uid_key, sla_type, actual, threshold in (
+        ("sla_freshness", "freshness", 2, 24),
+        ("sla_quality", "quality_score", 98.5, 90),
+    ):
+        session.execute(text("""
+            INSERT INTO public.quality_sla_events (
+                uid, run_uid, asset_uid, sla_type, status, severity, actual,
+                threshold, owner_uid, escalation_level, evidence
+            ) VALUES (
+                CAST(:uid AS uuid), CAST(:quality_run AS uuid), CAST(:asset AS uuid),
+                :sla_type, 'met', 'info', :actual, :threshold,
+                CAST(:owner AS uuid), 0, '{}'::jsonb
+            )
+        """), {**ids, "uid": ids[uid_key], "sla_type": sla_type, "actual": actual, "threshold": threshold, "owner": owner_uid})
+    session.execute(text("""
+        INSERT INTO public.data_rules (
+            id, rule_uid, name, category, owner_uid, status
+        ) VALUES (
+            CAST(:rule AS uuid), CAST(:rule AS uuid), 'WP08 product rule',
+            'quality', CAST(:owner AS uuid), 'active'
+        )
+    """), {**ids, "owner": owner_uid})
+    session.execute(text("""
+        INSERT INTO public.data_rule_versions (
+            id, rule_uid, version_no, source_text, source_language, rule_spec,
+            spec_hash, generated_kind, status, created_by, published_at
+        ) VALUES (
+            CAST(:rule_version AS uuid), CAST(:rule AS uuid), 1,
+            '设备编码不能为空', 'zh-CN', '{}'::jsonb, :hash, 'rulespec',
+            'published', CAST(:owner AS uuid), :now
+        )
+    """), {**ids, "owner": owner_uid, "hash": "d" * 64, "now": now})
+    session.execute(text("""
+        INSERT INTO public.dataflow_workflow_versions (
+            id, dataflow_uid, environment, version_no, n8n_workflow_id,
+            n8n_workflow_name, definition_hash, definition_snapshot, status,
+            engine_definition_id, created_by, activated_by, activated_at
+        ) VALUES (
+            CAST(:workflow_version AS uuid), CAST(:dataflow AS uuid), 'production',
+            1, :workflow_id, 'WP08 product workflow', :hash, '{}'::jsonb,
+            'active', :workflow_id, CAST(:owner AS uuid),
+            CAST(:owner AS uuid), :now
+        )
+    """), {**ids, "owner": owner_uid, "workflow_id": f"wp08-{ids['dataflow'][:8]}", "hash": "e" * 64, "now": now})
+    session.execute(text("""
+        INSERT INTO public.workflow_runs (
+            id, workflow_version_id, engine_type, engine_execution_id,
+            trigger_type, status, correlation_id, started_at, finished_at
+        ) VALUES (
+            CAST(:workflow_run AS uuid), CAST(:workflow_version AS uuid), 'n8n',
+            :execution_id, 'manual', 'success', CAST(:workflow_run AS uuid),
+            :started_at, :finished_at
+        )
+    """), {**ids, "execution_id": f"wp08-{ids['workflow_run']}", "started_at": now - timedelta(minutes=2), "finished_at": now})
+    session.flush()
+    return ids
+
+
+def test_second_domain_product_completes_governed_lifecycle_in_postgres():
+    database_url = os.environ.get("TEST_DATABASE_URL")
+    if not database_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+    engine = create_engine(database_url)
+    connection = engine.connect()
+    transaction = connection.begin()
+    session = Session(bind=connection)
+    owner_uid, reviewer_uid, domain_uid = _uid(), _uid(), _uid()
+    try:
+        for uid, role in ((owner_uid, "owner"), (reviewer_uid, "reviewer")):
+            session.execute(text("""
+                INSERT INTO public.users (
+                    id, username, display_name, password_hash, status
+                ) VALUES (
+                    CAST(:uid AS uuid), :username, :display_name,
+                    'wp08-integration-only', 'active'
+                )
+            """), {"uid": uid, "username": f"wp08-{role}-{uid[:8]}", "display_name": f"WP08 {role}"})
+        legacy_product_id = session.execute(text("""
+            INSERT INTO public.data_products (
+                product_name, product_name_en, description,
+                target_table, target_schema, status, created_by
+            ) VALUES (
+                '设备健康数据产品', 'Device Health Product',
+                'P2-WP08 second-domain integration product',
+                :table_name, 'equipment_ops', 'active', 'wp08'
+            ) RETURNING id
+        """), {"table_name": f"device_health_{domain_uid[:8]}"}).scalar_one()
+        evidence = _seed_certificate_evidence(session, owner_uid, domain_uid)
+
+        center = UnifiedWorkCenterService(
+            SqlAlchemyWorkCenterRepository(session),
+            commit=session.flush,
+            rollback=session.rollback,
+        )
+        workflow = center.create_workflow({
+            "code": f"WP08_{domain_uid[:8].upper()}",
+            "name": "WP08 data-product approval",
+            "subject_types": ["data_product"],
+            "routes": [],
+            "default_route": {
+                "approval_mode": "any", "reviewer_uids": [reviewer_uid],
+                "min_approvals": 1, "due_hours": 24,
+                "timeout_action": "close", "notification_channels": ["in_app"],
+            },
+        }, actor_uid=owner_uid)
+        workflow = center.publish_workflow(
+            workflow["uid"], expected_version=1, actor_uid=owner_uid
+        )
+        service = ProductGovernanceService(
+            SqlAlchemyProductGovernanceRepository(session),
+            approval_gateway=WorkCenterProductApprovalGateway(session),
+            commit=session.flush,
+            rollback=session.rollback,
+        )
+        product = service.register_product({
+            "legacy_product_id": legacy_product_id,
+            "product_code": f"DEVICE_HEALTH_{domain_uid[:8].upper()}",
+            "name": "设备健康数据产品",
+            "product_type": "data_product",
+            "owner_uid": owner_uid,
+            "business_domain_uid": domain_uid,
+            "description": "第二业务域设备运行与维护产品",
+            "quality_target": 90,
+            "sla": {"availability_target": 99, "freshness_hours": 24, "support_tier": "business_hours"},
+        }, actor_uid=owner_uid)
+
+        application = service.create_application({
+            "title": "申请设备健康 API 数据",
+            "source_type": "api",
+            "source_ref": {"endpoint": "/device-health"},
+            "business_domain_uid": domain_uid,
+            "purpose": "设备预测性维护",
+            "requested_fields": ["device_id", "health_score"],
+        }, actor_uid=reviewer_uid)
+        application = service.submit_application(
+            application["uid"], {"workflow_uid": workflow["uid"]},
+            expected_version=1, actor_uid=reviewer_uid,
+        )
+        task = center.review_task(
+            application["approval_task_uid"],
+            {"decision": "approve", "reason": "第二业务域用途与责任清晰"},
+            expected_version=1, actor_uid=reviewer_uid,
+        )
+        application = service.reconcile_application(
+            application["uid"], expected_version=2, actor_uid=owner_uid
+        )
+        application = service.fulfill_application(
+            application["uid"], product["uid"],
+            expected_version=3, actor_uid=owner_uid,
+        )
+
+        definition = {
+            "schema": {"fields": [
+                {"name": "device_id", "type": "string", "nullable": False},
+                {"name": "health_score", "type": "number", "nullable": False},
+            ]},
+            "delivery": {"mode": "product", "format": "table"},
+            "quality_terms": {"minimum_score": 90},
+            "sla_terms": {"freshness_hours": 24, "availability_target": 99},
+            "usage_terms": {"purpose": "设备预测性维护", "retention_days": 365},
+            "compatibility_mode": "backward",
+            "change_reason": "建立第二业务域首版合同",
+        }
+        contract = service.create_contract(product["uid"], definition, actor_uid=owner_uid)
+        contract = service.publish_contract(
+            contract["uid"], expected_version=1, actor_uid=owner_uid
+        )
+        certificate = service.generate_certificate(product["uid"], {
+            "approval_task_uid": task["uid"],
+            "evidence": {
+                "quality_run_uid": evidence["quality_run"],
+                "lineage_uids": [evidence["lineage"]],
+                "rule_version_uids": [evidence["rule_version"]],
+                "workflow_run_uids": [evidence["workflow_run"]],
+            },
+        }, actor_uid=owner_uid)
+        product = service.transition_product(
+            product["uid"], {"action": "activate", "reason": "合同与合格证门禁已满足"},
+            expected_version=1, actor_uid=owner_uid,
+        )
+        feedback = service.create_feedback(product["uid"], {
+            "category": "usability", "rating": 4,
+            "summary": "增加健康分解释", "details": "需要说明评分组成",
+        }, actor_uid=reviewer_uid)
+        feedback = service.transition_feedback(feedback["uid"], {
+            "action": "triage", "assignee_uid": owner_uid, "note": "纳入改进",
+        }, expected_version=1, actor_uid=owner_uid)
+        feedback = service.transition_feedback(feedback["uid"], {
+            "action": "start", "note": "开始补充说明",
+        }, expected_version=2, actor_uid=owner_uid)
+        feedback = service.transition_feedback(feedback["uid"], {
+            "action": "resolve", "note": "产品说明已补充评分口径",
+            "evidence_refs": ["doc://device-health/score-definition"],
+        }, expected_version=3, actor_uid=owner_uid)
+        feedback = service.transition_feedback(feedback["uid"], {
+            "action": "close", "note": "用户确认改进有效",
+        }, expected_version=4, actor_uid=reviewer_uid)
+
+        detail = service.product_detail(product["uid"])
+        assert application["status"] == "fulfilled"
+        assert contract["status"] == "active"
+        assert certificate["status"] == "qualified"
+        assert product["status"] == "active"
+        assert feedback["status"] == "closed"
+        assert detail["business_domain_uid"] == domain_uid
+        assert detail["certificates"][0]["evidence_snapshot"]["quality"]["uid"] == evidence["quality_run"]
+        assert detail["feedback"][0]["evidence_refs"] == ["doc://device-health/score-definition"]
+        outbox_types = {
+            row[0]
+            for row in session.execute(
+                text("""
+                    SELECT event_type FROM public.outbox_events
+                    WHERE aggregate_type = 'governed_data_product'
+                      AND aggregate_id = :product_uid
+                """),
+                {"product_uid": product["uid"]},
+            )
+        }
+        assert {
+            "data_product.contract.published",
+            "data_product.certificate.issued",
+            "data_product.feedback.resolved",
+        } <= outbox_types
+    finally:
+        session.close()
+        transaction.rollback()
+        connection.close()
+        engine.dispose()

+ 159 - 0
tests/test_product_governance_api.py

@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+USER_UID = "01900000-0000-7000-8000-000000008801"
+PRODUCT_UID = "01900000-0000-7000-8000-000000008802"
+APPLICATION_UID = "01900000-0000-7000-8000-000000008803"
+CONTRACT_UID = "01900000-0000-7000-8000-000000008804"
+
+
+class FakeProductGovernanceService:
+    def __init__(self):
+        self.calls = []
+
+    def list_products(self, **filters):
+        self.calls.append(("list_products", filters))
+        return [{"uid": PRODUCT_UID, "name": "设备健康产品", "status": "draft"}]
+
+    def register_product(self, payload, actor_uid):
+        self.calls.append(("register_product", payload, actor_uid))
+        return {"uid": PRODUCT_UID, "current_version": 1, **payload}
+
+    def transition_product(self, uid, payload, expected_version, actor_uid):
+        self.calls.append(("transition_product", uid, payload, expected_version, actor_uid))
+        return {"uid": uid, "status": "in_review", "current_version": 2}
+
+    def create_application(self, payload, actor_uid):
+        self.calls.append(("create_application", payload, actor_uid))
+        return {"uid": APPLICATION_UID, "current_version": 1, **payload}
+
+    def submit_application(self, uid, payload, expected_version, actor_uid):
+        self.calls.append(("submit_application", uid, payload, expected_version, actor_uid))
+        return {"uid": uid, "status": "pending_approval", "current_version": 2}
+
+    def create_contract(self, product_uid, payload, actor_uid):
+        self.calls.append(("create_contract", product_uid, payload, actor_uid))
+        return {"uid": CONTRACT_UID, "product_uid": product_uid, "current_version": 1}
+
+    def generate_certificate(self, product_uid, payload, actor_uid):
+        self.calls.append(("generate_certificate", product_uid, payload, actor_uid))
+        return {"uid": "certificate-1", "product_uid": product_uid, "status": "qualified"}
+
+    def dashboard(self):
+        return {"product_count": 1, "qualified_count": 1}
+
+
+def _headers(role, **extra):
+    return {"Authorization": f"Bearer {role}", **extra}
+
+
+def _client(monkeypatch):
+    from app import create_app
+    from app.api.data_service import product_governance_routes
+
+    service = FakeProductGovernanceService()
+    monkeypatch.setattr(product_governance_routes, "_service", lambda: service)
+    monkeypatch.setattr(
+        "app.core.system.auth.load_identity_from_token",
+        lambda token, secret: (
+            {"id": USER_UID, "username": token, "roles": [token]}
+            if token in {"viewer", "editor", "admin"}
+            else None
+        ),
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    return app.test_client(), service
+
+
+def test_viewer_can_read_governed_products_and_dashboard(monkeypatch):
+    client, service = _client(monkeypatch)
+    products = client.get(
+        "/api/dataservice/governance/products?status=draft",
+        headers=_headers("viewer"),
+    )
+    assert products.status_code == 200
+    assert products.get_json()["data"][0]["uid"] == PRODUCT_UID
+    assert service.calls[-1] == ("list_products", {"status": "draft"})
+
+    dashboard = client.get(
+        "/api/dataservice/governance/dashboard", headers=_headers("viewer")
+    )
+    assert dashboard.status_code == 200
+    assert dashboard.get_json()["data"]["qualified_count"] == 1
+
+
+def test_editor_creates_and_submits_application_but_cannot_register_product(monkeypatch):
+    client, service = _client(monkeypatch)
+    payload = {
+        "title": "设备健康数据申请",
+        "source_type": "api",
+        "source_ref": {"endpoint": "/health"},
+    }
+    created = client.post(
+        "/api/dataservice/governance/applications",
+        json=payload,
+        headers=_headers("editor"),
+    )
+    assert created.status_code == 201
+    assert created.headers["ETag"] == '"1"'
+
+    missing_version = client.post(
+        f"/api/dataservice/governance/applications/{APPLICATION_UID}/submit",
+        json={"workflow_uid": "workflow-1"},
+        headers=_headers("editor"),
+    )
+    assert missing_version.status_code == 428
+    submitted = client.post(
+        f"/api/dataservice/governance/applications/{APPLICATION_UID}/submit",
+        json={"workflow_uid": "workflow-1"},
+        headers=_headers("editor", **{"If-Match": '"1"'}),
+    )
+    assert submitted.status_code == 200
+    assert submitted.headers["ETag"] == '"2"'
+    assert service.calls[-1][3] == 1
+
+    forbidden = client.post(
+        "/api/dataservice/governance/products",
+        json={"name": "不得登记"},
+        headers=_headers("editor"),
+    )
+    assert forbidden.status_code == 403
+    owner_transition = client.post(
+        f"/api/dataservice/governance/products/{PRODUCT_UID}/transition",
+        json={"action": "submit_review", "reason": "责任人提交"},
+        headers=_headers("editor", **{"If-Match": '"1"'}),
+    )
+    assert owner_transition.status_code == 200
+
+
+def test_admin_manages_lifecycle_contract_and_certificate(monkeypatch):
+    client, service = _client(monkeypatch)
+    product = client.post(
+        "/api/dataservice/governance/products",
+        json={"name": "设备健康产品"},
+        headers=_headers("admin"),
+    )
+    assert product.status_code == 201
+    assert product.headers["ETag"] == '"1"'
+
+    transitioned = client.post(
+        f"/api/dataservice/governance/products/{PRODUCT_UID}/transition",
+        json={"action": "submit_review", "reason": "治理评审"},
+        headers=_headers("admin", **{"If-Match": '"1"'}),
+    )
+    assert transitioned.status_code == 200
+    assert transitioned.headers["ETag"] == '"2"'
+
+    contract = client.post(
+        f"/api/dataservice/governance/products/{PRODUCT_UID}/contracts",
+        json={"definition": {"schema": {"fields": []}}},
+        headers=_headers("admin"),
+    )
+    assert contract.status_code == 201
+    certificate = client.post(
+        f"/api/dataservice/governance/products/{PRODUCT_UID}/certificates",
+        json={"approval_task_uid": "task-1", "evidence_refs": {}},
+        headers=_headers("admin"),
+    )
+    assert certificate.status_code == 201
+    assert certificate.get_json()["data"]["status"] == "qualified"

+ 126 - 0
tests/test_product_governance_contract.py

@@ -0,0 +1,126 @@
+from pathlib import Path
+
+from app.core.system.permissions import (
+    DATA_PRODUCTS_MANAGE,
+    DATA_PRODUCTS_OPERATE,
+    DATA_PRODUCTS_READ,
+    permission_for_request,
+    permissions_for_roles,
+)
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_product_governance_migration_is_additive_versioned_and_audited():
+    migration = (
+        ROOT / "migrations/versions/20260802_440_product_governance.py"
+    ).read_text(encoding="utf-8")
+    assert 'revision = "20260802_440"' in migration
+    assert 'down_revision = "20260802_430"' in migration
+    for table in (
+        "governed_data_products",
+        "data_product_governance_events",
+        "data_product_applications",
+        "data_product_application_events",
+        "data_product_contracts",
+        "data_product_contract_versions",
+        "data_product_certificates",
+        "data_product_feedback",
+    ):
+        assert f"CREATE TABLE public.{table}" in migration
+    assert "REFERENCES public.data_products(id)" in migration
+    assert "REFERENCES public.governance_tasks(uid)" in migration
+    assert "raise RuntimeError" in migration
+    assert "DROP TABLE" not in migration.upper()
+
+
+def test_product_governance_permissions_separate_read_operate_and_manage():
+    assert DATA_PRODUCTS_READ in permissions_for_roles(["viewer"])
+    assert DATA_PRODUCTS_OPERATE not in permissions_for_roles(["viewer"])
+    assert DATA_PRODUCTS_OPERATE in permissions_for_roles(["editor"])
+    assert DATA_PRODUCTS_MANAGE not in permissions_for_roles(["editor"])
+    assert DATA_PRODUCTS_MANAGE in permissions_for_roles(["admin"])
+    assert permission_for_request(
+        "/api/dataservice/governance/products", "GET"
+    ) == (DATA_PRODUCTS_READ,)
+    assert permission_for_request(
+        "/api/dataservice/governance/applications", "POST"
+    ) == (DATA_PRODUCTS_OPERATE,)
+    assert permission_for_request(
+        "/api/dataservice/governance/feedback/x/transition", "POST"
+    ) == (DATA_PRODUCTS_OPERATE,)
+    assert permission_for_request(
+        "/api/dataservice/governance/products/x/contracts", "POST"
+    ) == (DATA_PRODUCTS_OPERATE,)
+
+
+def test_api_and_frontend_cover_the_complete_product_governance_surface():
+    api = (
+        ROOT / "app/api/data_service/product_governance_routes.py"
+    ).read_text(encoding="utf-8")
+    client = (ROOT / "frontend/src/api/productGovernance.js").read_text(
+        encoding="utf-8"
+    )
+    view = (
+        ROOT / "frontend/src/views/dataService/productGovernance/index.vue"
+    ).read_text(encoding="utf-8")
+    routes = (ROOT / "frontend/src/router/routes.js").read_text(encoding="utf-8")
+
+    for path in (
+        "/governance/products",
+        "/governance/applications",
+        "/contracts",
+        "/compatibility",
+        "/certificates",
+        "/feedback",
+        "/governance/dashboard",
+    ):
+        assert path in api
+    for operation in (
+        "registerGovernedProduct",
+        "transitionGovernedProduct",
+        "createProductApplication",
+        "submitProductApplication",
+        "reconcileProductApplication",
+        "fulfillProductApplication",
+        "createProductContract",
+        "checkProductContractCompatibility",
+        "reviseProductContract",
+        "publishProductContract",
+        "terminateProductContract",
+        "issueProductCertificate",
+        "createProductFeedback",
+        "transitionProductFeedback",
+    ):
+        assert operation in client
+    for capability in (
+        "数据产品治理",
+        "通用申请",
+        "数据合同",
+        "产品合格证",
+        "用户反馈",
+        "不会自动下发数据权限",
+    ):
+        assert capability in view
+    assert "/dataService/productGovernance" in routes
+    assert "data-products:read" in routes
+
+
+def test_certificate_reads_canonical_evidence_and_never_grants_access():
+    repository = (
+        ROOT / "app/core/data_service/product_governance_repository.py"
+    ).read_text(encoding="utf-8")
+    service = (
+        ROOT / "app/core/data_service/product_governance.py"
+    ).read_text(encoding="utf-8")
+    for evidence_table in (
+        "quality_profile_runs",
+        "quality_sla_events",
+        "active_metadata_lineage",
+        "data_rule_versions",
+        "workflow_runs",
+    ):
+        assert evidence_table in repository
+    assert "UnifiedWorkCenterService" in repository
+    assert "approval_gateway.get_task" in service
+    assert '"grants_data_access": False' in service