Browse Source

feat: unify governance responsibility operations

马小龙 2 weeks ago
parent
commit
94905abc29

+ 248 - 0
app/api/system/responsibilities.py

@@ -11,6 +11,12 @@ from app.core.governance.responsibilities import (
     ResponsibilityValidationError,
     SqlAlchemyResponsibilityRepository,
 )
+from app.core.governance.unified_responsibilities import (
+    UnifiedResponsibilityService,
+)
+from app.core.governance.unified_responsibility_repository import (
+    SqlAlchemyUnifiedResponsibilityRepository,
+)
 from app.core.system.permissions import (
     RESPONSIBILITIES_MANAGE,
     RESPONSIBILITIES_READ,
@@ -23,6 +29,14 @@ def _service() -> ResponsibilityService:
     return ResponsibilityService(SqlAlchemyResponsibilityRepository(db.session))
 
 
+def _unified_service() -> UnifiedResponsibilityService:
+    return UnifiedResponsibilityService(
+        SqlAlchemyUnifiedResponsibilityRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
 def _etag(response, revision: int):
     response.headers["ETag"] = f'"{int(revision)}"'
     return response
@@ -38,6 +52,20 @@ def _expected_revision() -> int:
     return int(raw)
 
 
+def _unified_error(exc: Exception):
+    db.session.rollback()
+    message = str(exc)
+    if "If-Match" in message:
+        status = 428
+    elif isinstance(exc, LookupError):
+        status = 404
+    elif isinstance(exc, RuntimeError):
+        status = 409
+    else:
+        status = 400
+    return jsonify(failed(message, code=status)), status
+
+
 @bp.route(
     "/responsibilities/<resource_type>/<resource_uid>",
     methods=["GET"],
@@ -79,3 +107,223 @@ def replace_responsibility_matrix(resource_type: str, resource_uid: str):
         db.session.rollback()
         status = 428 if "If-Match" in str(exc) else 400
         return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route(
+    "/responsibilities/<resource_type>/<resource_uid>/resolved",
+    methods=["GET"],
+)
+@require_permissions(RESPONSIBILITIES_READ)
+def resolve_responsibility(resource_type: str, resource_uid: str):
+    try:
+        return jsonify(
+            success(
+                _unified_service().resolve(
+                    resource_type,
+                    resource_uid,
+                    at=request.args.get("at"),
+                )
+            )
+        )
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/hierarchy/<resource_type>/<resource_uid>",
+    methods=["PUT"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def replace_responsibility_parent(resource_type: str, resource_uid: str):
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _unified_service().set_parent(
+            {
+                **body,
+                "resource_type": resource_type,
+                "resource_uid": resource_uid,
+            },
+            expected_revision=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任继承关系已更新")), result["revision"]
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/delegations", methods=["GET"])
+@require_permissions(RESPONSIBILITIES_READ)
+def list_responsibility_delegations():
+    try:
+        return jsonify(
+            success(
+                _unified_service().list_delegations(
+                    status=request.args.get("status")
+                )
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/delegations", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def create_responsibility_delegation():
+    try:
+        result = _unified_service().create_delegation(
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "责任委派已创建", code=201)), 201
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/delegations/<delegation_uid>/revoke",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def revoke_responsibility_delegation(delegation_uid: str):
+    try:
+        result = _unified_service().revoke_delegation(
+            delegation_uid,
+            expected_version=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任委派已收回")), result["current_version"]
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/delegations/expire", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def expire_responsibility_delegations():
+    try:
+        body = request.get_json(silent=True) or {}
+        return jsonify(
+            success(
+                _unified_service().expire_delegations(
+                    at=body.get("at"), actor_uid=g.current_user["id"]
+                ),
+                "到期委派已收回",
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/transfers", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def transfer_departing_responsibility():
+    try:
+        result = _unified_service().transfer_departing_user(
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "离岗责任已转交", code=201)), 201
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/policies", methods=["GET"])
+@require_permissions(RESPONSIBILITIES_READ)
+def list_responsibility_policies():
+    try:
+        return jsonify(success(_unified_service().list_policies()))
+    except RuntimeError as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/policies", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def create_responsibility_policy():
+    try:
+        result = _unified_service().create_policy(
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任策略草稿已创建", code=201)),
+            result["current_version"],
+        ), 201
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/policies/<policy_uid>/revisions",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def revise_responsibility_policy(policy_uid: str):
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _unified_service().revise_policy(
+            policy_uid,
+            body.get("definition"),
+            expected_version=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任策略新版本已创建")),
+            result["current_version"],
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/policies/<policy_uid>/publish",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def publish_responsibility_policy(policy_uid: str):
+    try:
+        result = _unified_service().publish_policy(
+            policy_uid,
+            expected_version=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任策略已发布")),
+            result["current_version"],
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/<resource_type>/<resource_uid>/joint-review/evaluate",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def evaluate_responsibility_joint_review(resource_type: str, resource_uid: str):
+    try:
+        body = request.get_json(silent=True) or {}
+        return jsonify(
+            success(
+                _unified_service().evaluate_joint_review(
+                    resource_type, resource_uid, body.get("decisions")
+                )
+            )
+        )
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/operations", methods=["GET"])
+@require_permissions(RESPONSIBILITIES_READ)
+def responsibility_operations():
+    try:
+        return jsonify(
+            success(
+                _unified_service().operations(request.args.get("owner_uid"))
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)

+ 21 - 3
app/core/governance/responsibilities.py

@@ -20,6 +20,13 @@ RESOURCE_TYPES = frozenset(
         "device_quality",
         "fault_classification",
         "quality_issue",
+        "organization",
+        "data_asset",
+        "semantic_term",
+        "data_standard",
+        "quality_policy",
+        "data_product",
+        "agent",
     }
 )
 DEVICE_RESOURCE_TYPES = frozenset(
@@ -32,7 +39,18 @@ DEVICE_RESOURCE_TYPES = frozenset(
     }
 )
 RESPONSIBILITY_ROLES = frozenset(
-    {"domain_owner", "data_steward", "data_architect", "asset_manager"}
+    {
+        "organization_owner",
+        "domain_owner",
+        "data_steward",
+        "data_architect",
+        "asset_manager",
+        "term_steward",
+        "standard_owner",
+        "quality_owner",
+        "product_owner",
+        "agent_owner",
+    }
 )
 RACI_ROLES = frozenset({"responsible", "accountable", "consulted", "informed"})
 
@@ -82,8 +100,8 @@ def validate_matrix(
     assignments: list[dict[str, Any]],
 ) -> tuple[ResponsibilityAssignment, ...]:
     validate_resource(resource_type)
-    if not isinstance(assignments, list) or not assignments:
-        raise ResponsibilityValidationError("responsibility matrix cannot be empty")
+    if not isinstance(assignments, list):
+        raise ResponsibilityValidationError("responsibility matrix must be an array")
 
     validated: list[ResponsibilityAssignment] = []
     identities: set[tuple[str, str, str]] = set()

+ 734 - 0
app/core/governance/unified_responsibilities.py

@@ -0,0 +1,734 @@
+"""Hierarchical responsibility resolution, delegation and central policies."""
+
+from __future__ import annotations
+
+import copy
+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
+from app.core.governance.responsibilities import (
+    RACI_ROLES,
+    RESPONSIBILITY_ROLES,
+)
+
+HIERARCHICAL_RESOURCE_TYPES = frozenset(
+    {
+        "organization",
+        "business_domain",
+        "data_asset",
+        "semantic_term",
+        "data_standard",
+        "quality_policy",
+        "data_product",
+        "agent",
+    }
+)
+POLICY_TYPES = frozenset({"central_policy", "joint_review"})
+DELEGATION_TYPES = frozenset({"temporary", "departure_transfer"})
+DECISIONS = frozenset({"approve", "reject"})
+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 = 500) -> 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 _time(value: Any, label: str) -> datetime:
+    if isinstance(value, datetime):
+        result = value
+    else:
+        try:
+            result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+        except (TypeError, ValueError) as error:
+            raise ValueError(f"{label} must be an ISO-8601 datetime") from error
+    if result.tzinfo is None:
+        raise ValueError(f"{label} must include a timezone")
+    return result
+
+
+def _resource_type(value: Any, label: str = "resource_type") -> str:
+    result = _string(value, label, 40)
+    if result not in HIERARCHICAL_RESOURCE_TYPES:
+        raise ValueError(f"unsupported {label}")
+    return result
+
+
+def _policy_definition(policy_type: str, value: Any) -> dict[str, Any]:
+    if policy_type == "central_policy":
+        definition = _closed(
+            value,
+            {
+                "required_responsibility_roles",
+                "require_unique_accountable",
+                "max_delegation_days",
+            },
+            "central policy definition",
+        )
+        roles = definition.get("required_responsibility_roles")
+        if not isinstance(roles, list) or not roles:
+            raise ValueError("central policy required roles are missing")
+        normalized_roles = sorted({_string(item, "required role", 40) for item in roles})
+        if not set(normalized_roles) <= RESPONSIBILITY_ROLES:
+            raise ValueError("central policy contains unsupported role")
+        if not isinstance(definition.get("require_unique_accountable"), bool):
+            raise ValueError("require_unique_accountable must be a boolean")
+        try:
+            days = int(definition.get("max_delegation_days"))
+        except (TypeError, ValueError) as error:
+            raise ValueError("max_delegation_days is invalid") from error
+        if days < 1 or days > 366:
+            raise ValueError("max_delegation_days must be between 1 and 366")
+        return {
+            "required_responsibility_roles": normalized_roles,
+            "require_unique_accountable": definition[
+                "require_unique_accountable"
+            ],
+            "max_delegation_days": days,
+        }
+    definition = _closed(
+        value,
+        {"business_domain_uids", "min_approvals", "require_all_domains"},
+        "joint review definition",
+    )
+    domains = definition.get("business_domain_uids")
+    if not isinstance(domains, list) or len(domains) < 2:
+        raise ValueError("joint review requires at least two business domains")
+    normalized_domains = sorted(
+        {_string(item, "business domain uid", 120) for item in domains}
+    )
+    try:
+        minimum = int(definition.get("min_approvals"))
+    except (TypeError, ValueError) as error:
+        raise ValueError("min_approvals is invalid") from error
+    if minimum < 1 or minimum > len(normalized_domains):
+        raise ValueError("min_approvals is outside the domain range")
+    if not isinstance(definition.get("require_all_domains"), bool):
+        raise ValueError("require_all_domains must be a boolean")
+    return {
+        "business_domain_uids": normalized_domains,
+        "min_approvals": minimum,
+        "require_all_domains": definition["require_all_domains"],
+    }
+
+
+class UnifiedResponsibilityService:
+    """Resolve effective accountability without granting platform access."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        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.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def _chain(self, resource_type: str, resource_uid: str) -> list[dict[str, Any]]:
+        kind = _resource_type(resource_type)
+        uid = _string(resource_uid, "resource_uid", 120)
+        chain = []
+        seen = set()
+        for depth in range(20):
+            identity = (kind, uid)
+            if identity in seen:
+                raise ValueError("responsibility hierarchy contains a cycle")
+            seen.add(identity)
+            parent = self.repository.parent(kind, uid)
+            chain.append(
+                {
+                    "resource_type": kind,
+                    "resource_uid": uid,
+                    "depth": depth,
+                    "hierarchy_revision": int(parent["revision"]) if parent else 0,
+                }
+            )
+            if parent is None:
+                return chain
+            kind = _resource_type(parent["parent_type"], "parent_type")
+            uid = _string(parent["parent_uid"], "parent_uid", 120)
+        raise ValueError("responsibility hierarchy exceeds 20 levels")
+
+    def resolve(
+        self,
+        resource_type: str,
+        resource_uid: str,
+        *,
+        at: datetime | str | None = None,
+    ) -> dict[str, Any]:
+        evaluated_at = _time(at, "at") if at is not None else self.now_factory()
+        chain = self._chain(resource_type, resource_uid)
+        effective_by_raci: dict[str, list[dict[str, Any]]] = {}
+        for node in chain:
+            matrix = self.repository.get(
+                node["resource_type"], node["resource_uid"]
+            )
+            assignments = matrix.get("assignments") or []
+            for raci_role in RACI_ROLES:
+                if raci_role in effective_by_raci:
+                    continue
+                matching = [
+                    item for item in assignments if item["raci_role"] == raci_role
+                ]
+                if matching:
+                    effective_by_raci[raci_role] = [
+                        {
+                            **copy.deepcopy(item),
+                            "assigned_user_id": item["user_id"],
+                            "source_resource_type": node["resource_type"],
+                            "source_resource_uid": node["resource_uid"],
+                            "source_revision": matrix.get("revision", 0),
+                            "inheritance_depth": node["depth"],
+                            "inherited": node["depth"] > 0,
+                        }
+                        for item in matching
+                    ]
+        assignments = []
+        for raci_role in sorted(effective_by_raci):
+            for assignment in effective_by_raci[raci_role]:
+                delegation = self.repository.active_delegation(
+                    assignment["assigned_user_id"],
+                    assignment["responsibility_role"],
+                    chain,
+                    evaluated_at,
+                )
+                effective_user = (
+                    delegation["delegate_user_uid"]
+                    if delegation
+                    else assignment["assigned_user_id"]
+                )
+                active = effective_user in self.repository.users_available(
+                    [effective_user]
+                )
+                assignments.append(
+                    {
+                        **assignment,
+                        "effective_user_id": effective_user,
+                        "delegation_uid": delegation["uid"] if delegation else None,
+                        "delegation_type": (
+                            delegation["delegation_type"] if delegation else None
+                        ),
+                        "effective_user_active": active,
+                    }
+                )
+        final_owners = [
+            item
+            for item in assignments
+            if item["raci_role"] == "accountable"
+            and item["effective_user_active"]
+        ]
+        policies = self.repository.policies_for_chain(chain)
+        central = [
+            item for item in policies if item["policy_type"] == "central_policy"
+        ]
+        required_roles = sorted(
+            {
+                role
+                for policy in central
+                for role in policy["definition"]["required_responsibility_roles"]
+            }
+        )
+        present_roles = {item["responsibility_role"] for item in assignments}
+        missing_roles = sorted(set(required_roles) - present_roles)
+        unique_required = any(
+            item["definition"]["require_unique_accountable"] for item in central
+        )
+        compliant = not missing_roles and (
+            not unique_required or len(final_owners) == 1
+        )
+        joint_policies = [
+            item for item in policies if item["policy_type"] == "joint_review"
+        ]
+        joint = None
+        if joint_policies:
+            definition = joint_policies[0]["definition"]
+            joint = {
+                "policy_uid": joint_policies[0]["uid"],
+                "required_domains": definition["business_domain_uids"],
+                "min_approvals": definition["min_approvals"],
+                "require_all_domains": definition["require_all_domains"],
+            }
+        status = (
+            "resolved"
+            if len(final_owners) == 1
+            else "unresolved"
+            if not final_owners
+            else "ambiguous"
+        )
+        return {
+            "resource_type": chain[0]["resource_type"],
+            "resource_uid": chain[0]["resource_uid"],
+            "status": status,
+            "evaluated_at": evaluated_at.isoformat(),
+            "chain": chain,
+            "assignments": assignments,
+            "final_owners": final_owners,
+            "policy_compliance": {
+                "status": "compliant" if compliant else "non_compliant",
+                "required_roles": required_roles,
+                "missing_roles": missing_roles,
+                "unique_accountable_required": unique_required,
+            },
+            "joint_review": joint,
+            "grants_data_access": False,
+        }
+
+    def set_parent(
+        self,
+        payload: Any,
+        *,
+        expected_revision: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {"resource_type", "resource_uid", "parent_type", "parent_uid"},
+            "responsibility hierarchy",
+        )
+        resource_type = _resource_type(body.get("resource_type"))
+        resource_uid = _string(body.get("resource_uid"), "resource_uid", 120)
+        parent_type = _resource_type(body.get("parent_type"), "parent_type")
+        parent_uid = _string(body.get("parent_uid"), "parent_uid", 120)
+        if (resource_type, resource_uid) == (parent_type, parent_uid):
+            raise ValueError("responsibility hierarchy contains a cycle")
+        parent_chain = self._chain(parent_type, parent_uid)
+        if any(
+            (item["resource_type"], item["resource_uid"])
+            == (resource_type, resource_uid)
+            for item in parent_chain
+        ):
+            raise ValueError("responsibility hierarchy contains a cycle")
+        try:
+            revision = int(expected_revision)
+        except (TypeError, ValueError) as error:
+            raise ValueError("hierarchy revision is invalid") from error
+        now = self.now_factory().isoformat()
+        record = {
+            "resource_type": resource_type,
+            "resource_uid": resource_uid,
+            "parent_type": parent_type,
+            "parent_uid": parent_uid,
+            "updated_by": _uid(actor_uid, "actor_uid"),
+            "updated_at": now,
+        }
+        try:
+            result = self.repository.set_parent(record, revision)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def create_delegation(
+        self, payload: Any, *, actor_uid: str
+    ) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {
+                "source_user_uid",
+                "delegate_user_uid",
+                "scope_type",
+                "scope_uid",
+                "responsibility_role",
+                "delegation_type",
+                "starts_at",
+                "ends_at",
+                "reason",
+            },
+            "responsibility delegation",
+        )
+        source = _uid(body.get("source_user_uid"), "source_user_uid")
+        delegate = _uid(body.get("delegate_user_uid"), "delegate_user_uid")
+        if source == delegate:
+            raise ValueError("delegation users must be different")
+        delegation_type = _string(
+            body.get("delegation_type"), "delegation_type", 30
+        )
+        if delegation_type not in DELEGATION_TYPES:
+            raise ValueError("unsupported delegation type")
+        scope_type = body.get("scope_type")
+        scope_uid = body.get("scope_uid")
+        if (scope_type is None) != (scope_uid is None):
+            raise ValueError("delegation scope type and uid must be paired")
+        if scope_type is not None:
+            scope_type = _resource_type(scope_type, "scope_type")
+            scope_uid = _string(scope_uid, "scope_uid", 120)
+        role = body.get("responsibility_role")
+        if role is not None:
+            role = _string(role, "responsibility_role", 40)
+            if role not in RESPONSIBILITY_ROLES:
+                raise ValueError("unsupported responsibility role")
+        starts_at = _time(body.get("starts_at"), "starts_at")
+        ends_at = (
+            _time(body.get("ends_at"), "ends_at")
+            if body.get("ends_at") is not None
+            else None
+        )
+        if delegation_type == "temporary":
+            if ends_at is None or ends_at <= starts_at:
+                raise ValueError("temporary delegation requires a later end time")
+            if (ends_at - starts_at).days > 366:
+                raise ValueError("temporary delegation exceeds 366 days")
+            if scope_type is not None:
+                policies = self.repository.policies_for_chain(
+                    self._chain(scope_type, scope_uid)
+                )
+                limits = [
+                    int(item["definition"]["max_delegation_days"])
+                    for item in policies
+                    if item["policy_type"] == "central_policy"
+                ]
+                if limits and (ends_at - starts_at).total_seconds() > min(limits) * 86400:
+                    raise ValueError("temporary delegation exceeds central policy limit")
+        elif ends_at is not None:
+            raise ValueError("departure transfer cannot have an end time")
+        required_users = [delegate] + ([source] if delegation_type == "temporary" else [])
+        if self.repository.users_available(required_users) != set(required_users):
+            raise ValueError("delegation user is unknown or disabled")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "source_user_uid": source,
+            "delegate_user_uid": delegate,
+            "scope_type": scope_type,
+            "scope_uid": scope_uid,
+            "responsibility_role": role,
+            "delegation_type": delegation_type,
+            "starts_at": starts_at.isoformat(),
+            "ends_at": ends_at.isoformat() if ends_at else None,
+            "reason": _string(body.get("reason"), "reason", 500),
+            "status": "active",
+            "current_version": 1,
+            "created_by": _uid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "updated_by": _uid(actor_uid, "actor_uid"),
+            "updated_at": now,
+        }
+        try:
+            result = self.repository.create_delegation(record)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def transfer_departing_user(
+        self, payload: Any, *, actor_uid: str
+    ) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {
+                "source_user_uid",
+                "delegate_user_uid",
+                "scope_type",
+                "scope_uid",
+                "responsibility_role",
+                "reason",
+            },
+            "departure transfer",
+        )
+        return self.create_delegation(
+            {
+                **body,
+                "responsibility_role": body.get("responsibility_role"),
+                "delegation_type": "departure_transfer",
+                "starts_at": self.now_factory().isoformat(),
+                "ends_at": None,
+            },
+            actor_uid=actor_uid,
+        )
+
+    def revoke_delegation(
+        self, uid: str, *, expected_version: int, actor_uid: str
+    ) -> dict[str, Any]:
+        record = self.repository.get_delegation(_uid(uid, "delegation_uid"))
+        if record is None:
+            raise LookupError("delegation was not found")
+        if record["status"] != "active":
+            raise RuntimeError("delegation is not active")
+        record.update(
+            {
+                "status": "revoked",
+                "updated_by": _uid(actor_uid, "actor_uid"),
+                "updated_at": self.now_factory().isoformat(),
+            }
+        )
+        try:
+            result = self.repository.update_delegation(record, int(expected_version))
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def expire_delegations(
+        self, *, at: datetime | str | None = None, actor_uid: str
+    ) -> list[dict[str, Any]]:
+        timestamp = _time(at, "at") if at is not None else self.now_factory()
+        try:
+            result = self.repository.expire_delegations(
+                timestamp, _uid(actor_uid, "actor_uid")
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_delegations(self, *, status: str | None = None) -> list[dict[str, Any]]:
+        normalized = None
+        if status is not None:
+            normalized = _string(status, "status", 20)
+            if normalized not in {"active", "revoked", "expired"}:
+                raise ValueError("unsupported delegation status")
+        return self.repository.list_delegations(status=normalized)
+
+    def create_policy(self, payload: Any, *, actor_uid: str) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {
+                "code",
+                "name",
+                "policy_type",
+                "scope_type",
+                "scope_uid",
+                "definition",
+            },
+            "responsibility policy",
+        )
+        code = _string(body.get("code"), "code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("responsibility policy code is invalid")
+        policy_type = _string(body.get("policy_type"), "policy_type", 30)
+        if policy_type not in POLICY_TYPES:
+            raise ValueError("unsupported responsibility policy type")
+        actor = _uid(actor_uid, "actor_uid")
+        now = self.now_factory().isoformat()
+        policy_uid = self.uid_factory()
+        version_uid = self.uid_factory()
+        policy = {
+            "uid": policy_uid,
+            "code": code,
+            "name": _string(body.get("name"), "name", 300),
+            "policy_type": policy_type,
+            "scope_type": _resource_type(body.get("scope_type"), "scope_type"),
+            "scope_uid": _string(body.get("scope_uid"), "scope_uid", 120),
+            "status": "draft",
+            "current_version": 1,
+            "active_version_uid": None,
+            "created_by": actor,
+            "created_at": now,
+            "updated_at": now,
+        }
+        version = {
+            "uid": version_uid,
+            "policy_uid": policy_uid,
+            "version": 1,
+            "status": "draft",
+            "definition": _policy_definition(policy_type, body.get("definition")),
+            "created_by": actor,
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        try:
+            self.repository.create_policy(policy, version)
+            self.commit()
+            return {**policy, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_policies(self) -> list[dict[str, Any]]:
+        return self.repository.list_policies()
+
+    def revise_policy(
+        self,
+        policy_uid: str,
+        definition: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(policy_uid, "policy_uid")
+        policy = self.repository.get_policy(uid)
+        if policy is None:
+            raise LookupError("responsibility policy was not found")
+        if int(policy["current_version"]) != int(expected_version):
+            raise RuntimeError("policy version conflict")
+        now = self.now_factory().isoformat()
+        version_number = int(expected_version) + 1
+        version = {
+            "uid": self.uid_factory(),
+            "policy_uid": uid,
+            "version": version_number,
+            "status": "draft",
+            "definition": _policy_definition(policy["policy_type"], definition),
+            "created_by": _uid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        updated = {
+            **policy,
+            "current_version": version_number,
+            "updated_at": now,
+        }
+        try:
+            self.repository.revise_policy(updated, version, int(expected_version))
+            self.commit()
+            return {**updated, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def publish_policy(
+        self,
+        policy_uid: str,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(policy_uid, "policy_uid")
+        policy = self.repository.get_policy(uid)
+        if policy is None:
+            raise LookupError("responsibility policy was not found")
+        if int(policy["current_version"]) != int(expected_version):
+            raise RuntimeError("policy version conflict")
+        version = self.repository.policy_version(uid, int(expected_version))
+        if version is None or version["status"] != "draft":
+            raise RuntimeError("responsibility policy version is not publishable")
+        actor = _uid(actor_uid, "actor_uid")
+        now = self.now_factory().isoformat()
+        published_version = {
+            **version,
+            "status": "published",
+            "published_by": actor,
+            "published_at": now,
+        }
+        published = {
+            **policy,
+            "status": "published",
+            "active_version_uid": version["uid"],
+            "updated_at": now,
+        }
+        try:
+            self.repository.publish_policy(
+                published, published_version, int(expected_version)
+            )
+            self.commit()
+            return {**published, "active_version": published_version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def evaluate_joint_review(
+        self,
+        resource_type: str,
+        resource_uid: str,
+        decisions: Any,
+    ) -> dict[str, Any]:
+        resolved = self.resolve(resource_type, resource_uid)
+        policy = resolved.get("joint_review")
+        if policy is None:
+            raise LookupError("joint review policy was not found")
+        if not isinstance(decisions, list):
+            raise ValueError("joint review decisions must be an array")
+        by_domain = {}
+        seen_users = set()
+        for raw in decisions:
+            item = _closed(
+                raw,
+                {"user_uid", "domain_uid", "decision"},
+                "joint review decision",
+            )
+            user_uid = _uid(item.get("user_uid"), "user_uid")
+            domain_uid = _string(item.get("domain_uid"), "domain_uid", 120)
+            decision = _string(item.get("decision"), "decision", 20)
+            if decision not in DECISIONS:
+                raise ValueError("unsupported joint review decision")
+            if domain_uid not in policy["required_domains"]:
+                raise ValueError("review domain is not eligible")
+            domain = self.resolve("business_domain", domain_uid)
+            eligible_users = {
+                owner["effective_user_id"] for owner in domain["final_owners"]
+            }
+            if user_uid not in eligible_users:
+                raise ValueError("reviewer is not the final owner of the domain")
+            if user_uid in seen_users or domain_uid in by_domain:
+                raise ValueError("joint review decisions must be independent")
+            seen_users.add(user_uid)
+            by_domain[domain_uid] = decision
+        approved_domains = sorted(
+            domain for domain, decision in by_domain.items() if decision == "approve"
+        )
+        missing = sorted(set(policy["required_domains"]) - set(approved_domains))
+        if "reject" in by_domain.values():
+            status = "rejected"
+        elif len(approved_domains) < policy["min_approvals"] or (
+            policy["require_all_domains"] and missing
+        ):
+            status = "pending"
+        else:
+            status = "approved"
+        return {
+            "status": status,
+            "policy_uid": policy["policy_uid"],
+            "approval_count": len(approved_domains),
+            "approved_domains": approved_domains,
+            "missing_domains": missing,
+            "deterministic": True,
+        }
+
+    def operations(self, owner_uid: str) -> dict[str, Any]:
+        owner = _uid(owner_uid, "owner_uid")
+        result = self.repository.responsibility_operations(owner)
+        tasks = result.get("tasks") or []
+        metrics = result.get("metrics") or []
+        return {
+            "owner_uid": owner,
+            "tasks": tasks,
+            "metrics": metrics,
+            "summary": {
+                "task_count": len(tasks),
+                "metric_count": len(metrics),
+                "overdue_count": sum(bool(item.get("overdue")) for item in tasks),
+                "recurrent_count": sum(
+                    int(item.get("recurrence_count") or 0) > 1 for item in tasks
+                ),
+            },
+            "grants_data_access": False,
+        }

+ 635 - 0
app/core/governance/unified_responsibility_repository.py

@@ -0,0 +1,635 @@
+"""PostgreSQL persistence for unified governance responsibilities."""
+
+from __future__ import annotations
+
+import copy
+import json
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.governance.responsibilities import SqlAlchemyResponsibilityRepository
+
+
+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 key.endswith("uid") or key in {"uid", "created_by", "updated_by"}:
+            result[key] = str(value)
+    return result
+
+
+class SqlAlchemyUnifiedResponsibilityRepository:
+    def __init__(self, session):
+        self.session = session
+        self.matrix_repository = SqlAlchemyResponsibilityRepository(session)
+
+    def get(self, resource_type: str, resource_uid: str) -> dict[str, Any]:
+        return self.matrix_repository.get(resource_type, resource_uid)
+
+    def parent(self, resource_type: str, resource_uid: str):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, resource_type, resource_uid,
+                           parent_type, parent_uid, revision,
+                           updated_by::text AS updated_by, updated_at
+                    FROM public.governance_responsibility_hierarchy
+                    WHERE resource_type = :resource_type
+                      AND resource_uid = :resource_uid
+                    """
+                ),
+                {"resource_type": resource_type, "resource_uid": resource_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def _audit(
+        self,
+        *,
+        resource_type: str,
+        resource_uid: str,
+        actor_uid: str,
+        action: str,
+        before: Any,
+        after: Any,
+    ) -> None:
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_audit_events (
+                    resource_type, resource_uid, actor_uid, action,
+                    before_state, after_state
+                ) VALUES (
+                    :resource_type, :resource_uid, CAST(:actor_uid AS uuid),
+                    :action, CAST(:before AS jsonb), CAST(:after AS jsonb)
+                )
+                """
+            ),
+            {
+                "resource_type": resource_type,
+                "resource_uid": resource_uid,
+                "actor_uid": actor_uid,
+                "action": action,
+                "before": json.dumps(before, ensure_ascii=False),
+                "after": json.dumps(after, ensure_ascii=False),
+            },
+        )
+
+    def set_parent(self, record: dict[str, Any], expected_revision: int):
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext('responsibility-hierarchy'))")
+        )
+        current = self.parent(record["resource_type"], record["resource_uid"])
+        current_revision = int(current["revision"]) if current else 0
+        if current_revision != expected_revision:
+            raise RuntimeError("hierarchy revision conflict")
+        cycle = self.session.execute(
+            text(
+                """
+                WITH RECURSIVE ancestors(resource_type, resource_uid, depth) AS (
+                    SELECT CAST(:parent_type AS VARCHAR(40)),
+                           CAST(:parent_uid AS VARCHAR(120)), 0
+                    UNION ALL
+                    SELECT h.parent_type, h.parent_uid, a.depth + 1
+                    FROM ancestors a
+                    JOIN public.governance_responsibility_hierarchy h
+                      ON h.resource_type = a.resource_type
+                     AND h.resource_uid = a.resource_uid
+                    WHERE a.depth < 20
+                )
+                SELECT EXISTS (
+                    SELECT 1 FROM ancestors
+                    WHERE resource_type = :resource_type
+                      AND resource_uid = :resource_uid
+                )
+                """
+            ),
+            record,
+        ).scalar_one()
+        if cycle:
+            raise ValueError("responsibility hierarchy contains a cycle")
+        revision = current_revision + 1
+        uid = current["uid"] if current else new_governance_uid()
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_hierarchy (
+                    uid, resource_type, resource_uid, parent_type, parent_uid,
+                    revision, updated_by
+                ) VALUES (
+                    CAST(:uid AS uuid), :resource_type, :resource_uid,
+                    :parent_type, :parent_uid, :revision,
+                    CAST(:updated_by AS uuid)
+                )
+                ON CONFLICT (resource_type, resource_uid) DO UPDATE SET
+                    parent_type = EXCLUDED.parent_type,
+                    parent_uid = EXCLUDED.parent_uid,
+                    revision = EXCLUDED.revision,
+                    updated_by = EXCLUDED.updated_by,
+                    updated_at = CURRENT_TIMESTAMP
+                """
+            ),
+            {**record, "uid": uid, "revision": revision},
+        )
+        saved = self.parent(record["resource_type"], record["resource_uid"])
+        self._audit(
+            resource_type=record["resource_type"],
+            resource_uid=record["resource_uid"],
+            actor_uid=record["updated_by"],
+            action="hierarchy_replaced",
+            before=current or {},
+            after=saved,
+        )
+        return saved
+
+    def users_available(self, user_uids) -> set[str]:
+        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(:user_uids)
+                """
+            ),
+            {"user_uids": values},
+        )
+        return {str(row[0]) for row in rows}
+
+    def active_delegation(
+        self,
+        source_user_uid: str,
+        responsibility_role: str,
+        chain: list[dict[str, Any]],
+        at: datetime,
+    ):
+        rows = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid,
+                           source_user_uid::text AS source_user_uid,
+                           delegate_user_uid::text AS delegate_user_uid,
+                           scope_type, scope_uid, responsibility_role,
+                           delegation_type, starts_at, ends_at, reason,
+                           status, current_version,
+                           created_by::text AS created_by,
+                           updated_by::text AS updated_by,
+                           created_at, updated_at
+                    FROM public.governance_responsibility_delegations
+                    WHERE source_user_uid = CAST(:source_user_uid AS uuid)
+                      AND status = 'active'
+                      AND starts_at <= :evaluated_at
+                      AND (ends_at IS NULL OR ends_at > :evaluated_at)
+                      AND (
+                        responsibility_role IS NULL
+                        OR responsibility_role = :responsibility_role
+                      )
+                    """
+                ),
+                {
+                    "source_user_uid": source_user_uid,
+                    "responsibility_role": responsibility_role,
+                    "evaluated_at": at,
+                },
+            )
+            .mappings()
+            .all()
+        )
+        depths = {
+            (node["resource_type"], node["resource_uid"]): node["depth"]
+            for node in chain
+        }
+        candidates = []
+        for row in rows:
+            item = _plain(row)
+            scope = (item.get("scope_type"), item.get("scope_uid"))
+            if scope == (None, None):
+                depth = 999
+            elif scope in depths:
+                depth = depths[scope]
+            else:
+                continue
+            candidates.append(
+                (depth, 0 if item.get("responsibility_role") else 1, item)
+            )
+        return sorted(candidates, key=lambda value: value[:2])[0][2] if candidates else None
+
+    def create_delegation(self, record: dict[str, Any]):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_delegations (
+                    uid, source_user_uid, delegate_user_uid, scope_type,
+                    scope_uid, responsibility_role, delegation_type,
+                    starts_at, ends_at, reason, status, current_version,
+                    created_by, updated_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:source_user_uid AS uuid),
+                    CAST(:delegate_user_uid AS uuid), :scope_type, :scope_uid,
+                    :responsibility_role, :delegation_type, :starts_at,
+                    :ends_at, :reason, :status, :current_version,
+                    CAST(:created_by AS uuid), CAST(:updated_by AS uuid),
+                    :created_at, :updated_at
+                )
+                """
+            ),
+            record,
+        )
+        self._audit(
+            resource_type=record.get("scope_type") or "organization",
+            resource_uid=record.get("scope_uid") or "*",
+            actor_uid=record["created_by"],
+            action="delegation_created",
+            before={},
+            after=record,
+        )
+        return copy.deepcopy(record)
+
+    def get_delegation(self, uid: str):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid,
+                           source_user_uid::text AS source_user_uid,
+                           delegate_user_uid::text AS delegate_user_uid,
+                           scope_type, scope_uid, responsibility_role,
+                           delegation_type, starts_at, ends_at, reason,
+                           status, current_version,
+                           created_by::text AS created_by,
+                           updated_by::text AS updated_by,
+                           created_at, updated_at
+                    FROM public.governance_responsibility_delegations
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def list_delegations(self, *, status: str | None = None):
+        where = "WHERE status = :status" if status else ""
+        rows = (
+            self.session.execute(
+                text(
+                    f"""
+                    SELECT uid::text AS uid,
+                           source_user_uid::text AS source_user_uid,
+                           delegate_user_uid::text AS delegate_user_uid,
+                           scope_type, scope_uid, responsibility_role,
+                           delegation_type, starts_at, ends_at, reason,
+                           status, current_version,
+                           created_by::text AS created_by,
+                           updated_by::text AS updated_by,
+                           created_at, updated_at
+                    FROM public.governance_responsibility_delegations
+                    {where}
+                    ORDER BY created_at DESC, uid DESC
+                    """
+                ),
+                {"status": status} if status else {},
+            )
+            .mappings()
+            .all()
+        )
+        return [_plain(row) for row in rows]
+
+    def update_delegation(self, record: dict[str, Any], expected_version: int):
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_delegations
+                SET status = :status,
+                    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 result.rowcount != 1:
+            raise RuntimeError("delegation version conflict")
+        saved = self.get_delegation(record["uid"])
+        self._audit(
+            resource_type=record.get("scope_type") or "organization",
+            resource_uid=record.get("scope_uid") or "*",
+            actor_uid=record["updated_by"],
+            action=f"delegation_{record['status']}",
+            before=record,
+            after=saved,
+        )
+        return saved
+
+    def expire_delegations(self, at: datetime, actor_uid: str):
+        candidates = [
+            item
+            for item in self.list_delegations(status="active")
+            if item.get("ends_at")
+            and datetime.fromisoformat(item["ends_at"]) <= at
+        ]
+        return [
+            self.update_delegation(
+                {**item, "status": "expired", "updated_by": actor_uid, "updated_at": at.isoformat()},
+                int(item["current_version"]),
+            )
+            for item in candidates
+        ]
+
+    def create_policy(self, policy: dict[str, Any], version: dict[str, Any]):
+        self._insert_policy(policy)
+        self._insert_policy_version(version)
+        self._audit_policy("policy_created", policy, {}, {**policy, "version": version})
+        return copy.deepcopy(policy)
+
+    def _insert_policy(self, policy: dict[str, Any]):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_policies (
+                    uid, code, name, policy_type, scope_type, scope_uid,
+                    status, current_version, active_version_uid,
+                    created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :name, :policy_type,
+                    :scope_type, :scope_uid, :status, :current_version,
+                    CAST(:active_version_uid AS uuid), CAST(:created_by AS uuid),
+                    :created_at, :updated_at
+                )
+                """
+            ),
+            policy,
+        )
+
+    def _insert_policy_version(self, version: dict[str, Any]):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_policy_versions (
+                    uid, policy_uid, version, status, definition,
+                    created_by, created_at, published_by, published_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:policy_uid AS uuid), :version,
+                    :status, CAST(:definition 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)},
+        )
+
+    def _audit_policy(self, action, policy, before, after):
+        version = after.get("version") if isinstance(after, dict) else None
+        version = version if isinstance(version, dict) else {}
+        self._audit(
+            resource_type="responsibility_policy",
+            resource_uid=policy["uid"],
+            actor_uid=(after.get("published_by") if isinstance(after, dict) else None)
+            or version.get("published_by")
+            or version.get("created_by")
+            or policy["created_by"],
+            action=action,
+            before=before,
+            after=after,
+        )
+
+    def get_policy(self, uid: str):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, code, name, policy_type,
+                           scope_type, scope_uid, status, current_version,
+                           active_version_uid::text AS active_version_uid,
+                           created_by::text AS created_by, created_at, updated_at
+                    FROM public.governance_responsibility_policies
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def list_policies(self):
+        rows = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT p.uid::text AS uid, p.code, p.name, p.policy_type,
+                           p.scope_type, p.scope_uid, p.status,
+                           p.current_version,
+                           p.active_version_uid::text AS active_version_uid,
+                           p.created_by::text AS created_by,
+                           p.created_at, p.updated_at,
+                           v.definition AS active_definition
+                    FROM public.governance_responsibility_policies p
+                    LEFT JOIN public.governance_responsibility_policy_versions v
+                      ON v.uid = p.active_version_uid
+                    ORDER BY p.updated_at DESC, p.uid DESC
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        )
+        return [_plain(row) for row in rows]
+
+    def policy_version(self, policy_uid: str, version: int):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, policy_uid::text AS policy_uid,
+                           version, status, definition,
+                           created_by::text AS created_by, created_at,
+                           published_by::text AS published_by, published_at
+                    FROM public.governance_responsibility_policy_versions
+                    WHERE policy_uid = CAST(:policy_uid AS uuid)
+                      AND version = :version
+                    """
+                ),
+                {"policy_uid": policy_uid, "version": version},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def revise_policy(self, policy, version, expected_version):
+        current = self.get_policy(policy["uid"])
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_policies
+                SET status = :status, current_version = :current_version,
+                    active_version_uid = CAST(:active_version_uid AS uuid),
+                    updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :expected_version
+                """
+            ),
+            {**policy, "expected_version": expected_version},
+        )
+        if result.rowcount != 1:
+            raise RuntimeError("policy version conflict")
+        self._insert_policy_version(version)
+        saved = self.get_policy(policy["uid"])
+        self._audit_policy("policy_revised", policy, current, {**saved, "version": version})
+        return saved
+
+    def publish_policy(self, policy, version, expected_version):
+        current = self.get_policy(policy["uid"])
+        self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_policy_versions
+                SET status = 'superseded'
+                WHERE policy_uid = CAST(:policy_uid AS uuid)
+                  AND status = 'published'
+                """
+            ),
+            {"policy_uid": policy["uid"]},
+        )
+        version_result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_policy_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,
+        )
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_policies
+                SET status = 'published',
+                    active_version_uid = CAST(:active_version_uid AS uuid),
+                    updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :expected_version
+                """
+            ),
+            {**policy, "expected_version": expected_version},
+        )
+        if version_result.rowcount != 1 or result.rowcount != 1:
+            raise RuntimeError("policy version conflict")
+        saved = self.get_policy(policy["uid"])
+        self._audit_policy("policy_published", policy, current, {**saved, "version": version})
+        return saved
+
+    def policies_for_chain(self, chain: list[dict[str, Any]]):
+        result = []
+        for node in chain:
+            rows = (
+                self.session.execute(
+                    text(
+                        """
+                        SELECT p.uid::text AS uid, p.code, p.name,
+                               p.policy_type, p.scope_type, p.scope_uid,
+                               p.status, p.current_version,
+                               p.active_version_uid::text AS active_version_uid,
+                               p.created_by::text AS created_by,
+                               v.definition
+                        FROM public.governance_responsibility_policies p
+                        JOIN public.governance_responsibility_policy_versions v
+                          ON v.uid = p.active_version_uid
+                        WHERE p.status = 'published'
+                          AND p.scope_type = :scope_type
+                          AND p.scope_uid = :scope_uid
+                        ORDER BY p.policy_type, p.code
+                        """
+                    ),
+                    {"scope_type": node["resource_type"], "scope_uid": node["resource_uid"]},
+                )
+                .mappings()
+                .all()
+            )
+            result.extend({**_plain(row), "inheritance_depth": node["depth"]} for row in rows)
+        return result
+
+    def responsibility_operations(self, owner_uid: str):
+        tasks = []
+        quality_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, issue_code AS code, message AS title,
+                       status, priority, due_at,
+                       (due_at IS NOT NULL AND due_at < CURRENT_TIMESTAMP
+                        AND status <> 'closed') AS overdue,
+                       occurrence_number AS recurrence_count
+                FROM public.device_quality_issues
+                WHERE assignee_uid = CAST(:owner_uid AS uuid)
+                  AND status <> 'closed'
+                ORDER BY due_at NULLS LAST, created_at DESC
+                """
+            ),
+            {"owner_uid": owner_uid},
+        ).mappings()
+        tasks.extend({"kind": "quality_issue", **_plain(row)} for row in quality_rows)
+        incident_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, code, title, status, severity,
+                       FALSE AS overdue, 1 AS recurrence_count
+                FROM public.data_incidents
+                WHERE owner_uid = CAST(:owner_uid AS uuid)
+                  AND status <> 'closed'
+                ORDER BY updated_at DESC
+                """
+            ),
+            {"owner_uid": owner_uid},
+        ).mappings()
+        tasks.extend({"kind": "data_incident", **_plain(row)} for row in incident_rows)
+        correction_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, '元数据纠正' AS title, status,
+                       FALSE AS overdue, 1 AS recurrence_count
+                FROM public.active_metadata_corrections
+                WHERE assignee_uid = CAST(:owner_uid AS uuid)
+                  AND status = 'pending'
+                ORDER BY updated_at DESC
+                """
+            ),
+            {"owner_uid": owner_uid},
+        ).mappings()
+        tasks.extend({"kind": "metadata_correction", **_plain(row)} for row in correction_rows)
+        metric_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, code, name, sli_type, scope_type,
+                       scope_uid, operator, target, window_seconds, status
+                FROM public.data_slo_policies
+                WHERE owner_uid = CAST(:owner_uid AS uuid)
+                  AND status = 'active'
+                ORDER BY code
+                """
+            ),
+            {"owner_uid": owner_uid},
+        ).mappings()
+        metrics = [{"kind": "slo", **_plain(row)} for row in metric_rows]
+        return {"tasks": tasks, "metrics": metrics}

+ 248 - 0
deployment/app/api/system/responsibilities.py

@@ -11,6 +11,12 @@ from app.core.governance.responsibilities import (
     ResponsibilityValidationError,
     SqlAlchemyResponsibilityRepository,
 )
+from app.core.governance.unified_responsibilities import (
+    UnifiedResponsibilityService,
+)
+from app.core.governance.unified_responsibility_repository import (
+    SqlAlchemyUnifiedResponsibilityRepository,
+)
 from app.core.system.permissions import (
     RESPONSIBILITIES_MANAGE,
     RESPONSIBILITIES_READ,
@@ -23,6 +29,14 @@ def _service() -> ResponsibilityService:
     return ResponsibilityService(SqlAlchemyResponsibilityRepository(db.session))
 
 
+def _unified_service() -> UnifiedResponsibilityService:
+    return UnifiedResponsibilityService(
+        SqlAlchemyUnifiedResponsibilityRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
 def _etag(response, revision: int):
     response.headers["ETag"] = f'"{int(revision)}"'
     return response
@@ -38,6 +52,20 @@ def _expected_revision() -> int:
     return int(raw)
 
 
+def _unified_error(exc: Exception):
+    db.session.rollback()
+    message = str(exc)
+    if "If-Match" in message:
+        status = 428
+    elif isinstance(exc, LookupError):
+        status = 404
+    elif isinstance(exc, RuntimeError):
+        status = 409
+    else:
+        status = 400
+    return jsonify(failed(message, code=status)), status
+
+
 @bp.route(
     "/responsibilities/<resource_type>/<resource_uid>",
     methods=["GET"],
@@ -79,3 +107,223 @@ def replace_responsibility_matrix(resource_type: str, resource_uid: str):
         db.session.rollback()
         status = 428 if "If-Match" in str(exc) else 400
         return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route(
+    "/responsibilities/<resource_type>/<resource_uid>/resolved",
+    methods=["GET"],
+)
+@require_permissions(RESPONSIBILITIES_READ)
+def resolve_responsibility(resource_type: str, resource_uid: str):
+    try:
+        return jsonify(
+            success(
+                _unified_service().resolve(
+                    resource_type,
+                    resource_uid,
+                    at=request.args.get("at"),
+                )
+            )
+        )
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/hierarchy/<resource_type>/<resource_uid>",
+    methods=["PUT"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def replace_responsibility_parent(resource_type: str, resource_uid: str):
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _unified_service().set_parent(
+            {
+                **body,
+                "resource_type": resource_type,
+                "resource_uid": resource_uid,
+            },
+            expected_revision=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任继承关系已更新")), result["revision"]
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/delegations", methods=["GET"])
+@require_permissions(RESPONSIBILITIES_READ)
+def list_responsibility_delegations():
+    try:
+        return jsonify(
+            success(
+                _unified_service().list_delegations(
+                    status=request.args.get("status")
+                )
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/delegations", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def create_responsibility_delegation():
+    try:
+        result = _unified_service().create_delegation(
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "责任委派已创建", code=201)), 201
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/delegations/<delegation_uid>/revoke",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def revoke_responsibility_delegation(delegation_uid: str):
+    try:
+        result = _unified_service().revoke_delegation(
+            delegation_uid,
+            expected_version=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任委派已收回")), result["current_version"]
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/delegations/expire", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def expire_responsibility_delegations():
+    try:
+        body = request.get_json(silent=True) or {}
+        return jsonify(
+            success(
+                _unified_service().expire_delegations(
+                    at=body.get("at"), actor_uid=g.current_user["id"]
+                ),
+                "到期委派已收回",
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/transfers", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def transfer_departing_responsibility():
+    try:
+        result = _unified_service().transfer_departing_user(
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "离岗责任已转交", code=201)), 201
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/policies", methods=["GET"])
+@require_permissions(RESPONSIBILITIES_READ)
+def list_responsibility_policies():
+    try:
+        return jsonify(success(_unified_service().list_policies()))
+    except RuntimeError as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/policies", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def create_responsibility_policy():
+    try:
+        result = _unified_service().create_policy(
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任策略草稿已创建", code=201)),
+            result["current_version"],
+        ), 201
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/policies/<policy_uid>/revisions",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def revise_responsibility_policy(policy_uid: str):
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _unified_service().revise_policy(
+            policy_uid,
+            body.get("definition"),
+            expected_version=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任策略新版本已创建")),
+            result["current_version"],
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/policies/<policy_uid>/publish",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def publish_responsibility_policy(policy_uid: str):
+    try:
+        result = _unified_service().publish_policy(
+            policy_uid,
+            expected_version=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任策略已发布")),
+            result["current_version"],
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/<resource_type>/<resource_uid>/joint-review/evaluate",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def evaluate_responsibility_joint_review(resource_type: str, resource_uid: str):
+    try:
+        body = request.get_json(silent=True) or {}
+        return jsonify(
+            success(
+                _unified_service().evaluate_joint_review(
+                    resource_type, resource_uid, body.get("decisions")
+                )
+            )
+        )
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/operations", methods=["GET"])
+@require_permissions(RESPONSIBILITIES_READ)
+def responsibility_operations():
+    try:
+        return jsonify(
+            success(
+                _unified_service().operations(request.args.get("owner_uid"))
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)

+ 21 - 3
deployment/app/core/governance/responsibilities.py

@@ -20,6 +20,13 @@ RESOURCE_TYPES = frozenset(
         "device_quality",
         "fault_classification",
         "quality_issue",
+        "organization",
+        "data_asset",
+        "semantic_term",
+        "data_standard",
+        "quality_policy",
+        "data_product",
+        "agent",
     }
 )
 DEVICE_RESOURCE_TYPES = frozenset(
@@ -32,7 +39,18 @@ DEVICE_RESOURCE_TYPES = frozenset(
     }
 )
 RESPONSIBILITY_ROLES = frozenset(
-    {"domain_owner", "data_steward", "data_architect", "asset_manager"}
+    {
+        "organization_owner",
+        "domain_owner",
+        "data_steward",
+        "data_architect",
+        "asset_manager",
+        "term_steward",
+        "standard_owner",
+        "quality_owner",
+        "product_owner",
+        "agent_owner",
+    }
 )
 RACI_ROLES = frozenset({"responsible", "accountable", "consulted", "informed"})
 
@@ -82,8 +100,8 @@ def validate_matrix(
     assignments: list[dict[str, Any]],
 ) -> tuple[ResponsibilityAssignment, ...]:
     validate_resource(resource_type)
-    if not isinstance(assignments, list) or not assignments:
-        raise ResponsibilityValidationError("responsibility matrix cannot be empty")
+    if not isinstance(assignments, list):
+        raise ResponsibilityValidationError("responsibility matrix must be an array")
 
     validated: list[ResponsibilityAssignment] = []
     identities: set[tuple[str, str, str]] = set()

+ 734 - 0
deployment/app/core/governance/unified_responsibilities.py

@@ -0,0 +1,734 @@
+"""Hierarchical responsibility resolution, delegation and central policies."""
+
+from __future__ import annotations
+
+import copy
+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
+from app.core.governance.responsibilities import (
+    RACI_ROLES,
+    RESPONSIBILITY_ROLES,
+)
+
+HIERARCHICAL_RESOURCE_TYPES = frozenset(
+    {
+        "organization",
+        "business_domain",
+        "data_asset",
+        "semantic_term",
+        "data_standard",
+        "quality_policy",
+        "data_product",
+        "agent",
+    }
+)
+POLICY_TYPES = frozenset({"central_policy", "joint_review"})
+DELEGATION_TYPES = frozenset({"temporary", "departure_transfer"})
+DECISIONS = frozenset({"approve", "reject"})
+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 = 500) -> 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 _time(value: Any, label: str) -> datetime:
+    if isinstance(value, datetime):
+        result = value
+    else:
+        try:
+            result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+        except (TypeError, ValueError) as error:
+            raise ValueError(f"{label} must be an ISO-8601 datetime") from error
+    if result.tzinfo is None:
+        raise ValueError(f"{label} must include a timezone")
+    return result
+
+
+def _resource_type(value: Any, label: str = "resource_type") -> str:
+    result = _string(value, label, 40)
+    if result not in HIERARCHICAL_RESOURCE_TYPES:
+        raise ValueError(f"unsupported {label}")
+    return result
+
+
+def _policy_definition(policy_type: str, value: Any) -> dict[str, Any]:
+    if policy_type == "central_policy":
+        definition = _closed(
+            value,
+            {
+                "required_responsibility_roles",
+                "require_unique_accountable",
+                "max_delegation_days",
+            },
+            "central policy definition",
+        )
+        roles = definition.get("required_responsibility_roles")
+        if not isinstance(roles, list) or not roles:
+            raise ValueError("central policy required roles are missing")
+        normalized_roles = sorted({_string(item, "required role", 40) for item in roles})
+        if not set(normalized_roles) <= RESPONSIBILITY_ROLES:
+            raise ValueError("central policy contains unsupported role")
+        if not isinstance(definition.get("require_unique_accountable"), bool):
+            raise ValueError("require_unique_accountable must be a boolean")
+        try:
+            days = int(definition.get("max_delegation_days"))
+        except (TypeError, ValueError) as error:
+            raise ValueError("max_delegation_days is invalid") from error
+        if days < 1 or days > 366:
+            raise ValueError("max_delegation_days must be between 1 and 366")
+        return {
+            "required_responsibility_roles": normalized_roles,
+            "require_unique_accountable": definition[
+                "require_unique_accountable"
+            ],
+            "max_delegation_days": days,
+        }
+    definition = _closed(
+        value,
+        {"business_domain_uids", "min_approvals", "require_all_domains"},
+        "joint review definition",
+    )
+    domains = definition.get("business_domain_uids")
+    if not isinstance(domains, list) or len(domains) < 2:
+        raise ValueError("joint review requires at least two business domains")
+    normalized_domains = sorted(
+        {_string(item, "business domain uid", 120) for item in domains}
+    )
+    try:
+        minimum = int(definition.get("min_approvals"))
+    except (TypeError, ValueError) as error:
+        raise ValueError("min_approvals is invalid") from error
+    if minimum < 1 or minimum > len(normalized_domains):
+        raise ValueError("min_approvals is outside the domain range")
+    if not isinstance(definition.get("require_all_domains"), bool):
+        raise ValueError("require_all_domains must be a boolean")
+    return {
+        "business_domain_uids": normalized_domains,
+        "min_approvals": minimum,
+        "require_all_domains": definition["require_all_domains"],
+    }
+
+
+class UnifiedResponsibilityService:
+    """Resolve effective accountability without granting platform access."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        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.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def _chain(self, resource_type: str, resource_uid: str) -> list[dict[str, Any]]:
+        kind = _resource_type(resource_type)
+        uid = _string(resource_uid, "resource_uid", 120)
+        chain = []
+        seen = set()
+        for depth in range(20):
+            identity = (kind, uid)
+            if identity in seen:
+                raise ValueError("responsibility hierarchy contains a cycle")
+            seen.add(identity)
+            parent = self.repository.parent(kind, uid)
+            chain.append(
+                {
+                    "resource_type": kind,
+                    "resource_uid": uid,
+                    "depth": depth,
+                    "hierarchy_revision": int(parent["revision"]) if parent else 0,
+                }
+            )
+            if parent is None:
+                return chain
+            kind = _resource_type(parent["parent_type"], "parent_type")
+            uid = _string(parent["parent_uid"], "parent_uid", 120)
+        raise ValueError("responsibility hierarchy exceeds 20 levels")
+
+    def resolve(
+        self,
+        resource_type: str,
+        resource_uid: str,
+        *,
+        at: datetime | str | None = None,
+    ) -> dict[str, Any]:
+        evaluated_at = _time(at, "at") if at is not None else self.now_factory()
+        chain = self._chain(resource_type, resource_uid)
+        effective_by_raci: dict[str, list[dict[str, Any]]] = {}
+        for node in chain:
+            matrix = self.repository.get(
+                node["resource_type"], node["resource_uid"]
+            )
+            assignments = matrix.get("assignments") or []
+            for raci_role in RACI_ROLES:
+                if raci_role in effective_by_raci:
+                    continue
+                matching = [
+                    item for item in assignments if item["raci_role"] == raci_role
+                ]
+                if matching:
+                    effective_by_raci[raci_role] = [
+                        {
+                            **copy.deepcopy(item),
+                            "assigned_user_id": item["user_id"],
+                            "source_resource_type": node["resource_type"],
+                            "source_resource_uid": node["resource_uid"],
+                            "source_revision": matrix.get("revision", 0),
+                            "inheritance_depth": node["depth"],
+                            "inherited": node["depth"] > 0,
+                        }
+                        for item in matching
+                    ]
+        assignments = []
+        for raci_role in sorted(effective_by_raci):
+            for assignment in effective_by_raci[raci_role]:
+                delegation = self.repository.active_delegation(
+                    assignment["assigned_user_id"],
+                    assignment["responsibility_role"],
+                    chain,
+                    evaluated_at,
+                )
+                effective_user = (
+                    delegation["delegate_user_uid"]
+                    if delegation
+                    else assignment["assigned_user_id"]
+                )
+                active = effective_user in self.repository.users_available(
+                    [effective_user]
+                )
+                assignments.append(
+                    {
+                        **assignment,
+                        "effective_user_id": effective_user,
+                        "delegation_uid": delegation["uid"] if delegation else None,
+                        "delegation_type": (
+                            delegation["delegation_type"] if delegation else None
+                        ),
+                        "effective_user_active": active,
+                    }
+                )
+        final_owners = [
+            item
+            for item in assignments
+            if item["raci_role"] == "accountable"
+            and item["effective_user_active"]
+        ]
+        policies = self.repository.policies_for_chain(chain)
+        central = [
+            item for item in policies if item["policy_type"] == "central_policy"
+        ]
+        required_roles = sorted(
+            {
+                role
+                for policy in central
+                for role in policy["definition"]["required_responsibility_roles"]
+            }
+        )
+        present_roles = {item["responsibility_role"] for item in assignments}
+        missing_roles = sorted(set(required_roles) - present_roles)
+        unique_required = any(
+            item["definition"]["require_unique_accountable"] for item in central
+        )
+        compliant = not missing_roles and (
+            not unique_required or len(final_owners) == 1
+        )
+        joint_policies = [
+            item for item in policies if item["policy_type"] == "joint_review"
+        ]
+        joint = None
+        if joint_policies:
+            definition = joint_policies[0]["definition"]
+            joint = {
+                "policy_uid": joint_policies[0]["uid"],
+                "required_domains": definition["business_domain_uids"],
+                "min_approvals": definition["min_approvals"],
+                "require_all_domains": definition["require_all_domains"],
+            }
+        status = (
+            "resolved"
+            if len(final_owners) == 1
+            else "unresolved"
+            if not final_owners
+            else "ambiguous"
+        )
+        return {
+            "resource_type": chain[0]["resource_type"],
+            "resource_uid": chain[0]["resource_uid"],
+            "status": status,
+            "evaluated_at": evaluated_at.isoformat(),
+            "chain": chain,
+            "assignments": assignments,
+            "final_owners": final_owners,
+            "policy_compliance": {
+                "status": "compliant" if compliant else "non_compliant",
+                "required_roles": required_roles,
+                "missing_roles": missing_roles,
+                "unique_accountable_required": unique_required,
+            },
+            "joint_review": joint,
+            "grants_data_access": False,
+        }
+
+    def set_parent(
+        self,
+        payload: Any,
+        *,
+        expected_revision: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {"resource_type", "resource_uid", "parent_type", "parent_uid"},
+            "responsibility hierarchy",
+        )
+        resource_type = _resource_type(body.get("resource_type"))
+        resource_uid = _string(body.get("resource_uid"), "resource_uid", 120)
+        parent_type = _resource_type(body.get("parent_type"), "parent_type")
+        parent_uid = _string(body.get("parent_uid"), "parent_uid", 120)
+        if (resource_type, resource_uid) == (parent_type, parent_uid):
+            raise ValueError("responsibility hierarchy contains a cycle")
+        parent_chain = self._chain(parent_type, parent_uid)
+        if any(
+            (item["resource_type"], item["resource_uid"])
+            == (resource_type, resource_uid)
+            for item in parent_chain
+        ):
+            raise ValueError("responsibility hierarchy contains a cycle")
+        try:
+            revision = int(expected_revision)
+        except (TypeError, ValueError) as error:
+            raise ValueError("hierarchy revision is invalid") from error
+        now = self.now_factory().isoformat()
+        record = {
+            "resource_type": resource_type,
+            "resource_uid": resource_uid,
+            "parent_type": parent_type,
+            "parent_uid": parent_uid,
+            "updated_by": _uid(actor_uid, "actor_uid"),
+            "updated_at": now,
+        }
+        try:
+            result = self.repository.set_parent(record, revision)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def create_delegation(
+        self, payload: Any, *, actor_uid: str
+    ) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {
+                "source_user_uid",
+                "delegate_user_uid",
+                "scope_type",
+                "scope_uid",
+                "responsibility_role",
+                "delegation_type",
+                "starts_at",
+                "ends_at",
+                "reason",
+            },
+            "responsibility delegation",
+        )
+        source = _uid(body.get("source_user_uid"), "source_user_uid")
+        delegate = _uid(body.get("delegate_user_uid"), "delegate_user_uid")
+        if source == delegate:
+            raise ValueError("delegation users must be different")
+        delegation_type = _string(
+            body.get("delegation_type"), "delegation_type", 30
+        )
+        if delegation_type not in DELEGATION_TYPES:
+            raise ValueError("unsupported delegation type")
+        scope_type = body.get("scope_type")
+        scope_uid = body.get("scope_uid")
+        if (scope_type is None) != (scope_uid is None):
+            raise ValueError("delegation scope type and uid must be paired")
+        if scope_type is not None:
+            scope_type = _resource_type(scope_type, "scope_type")
+            scope_uid = _string(scope_uid, "scope_uid", 120)
+        role = body.get("responsibility_role")
+        if role is not None:
+            role = _string(role, "responsibility_role", 40)
+            if role not in RESPONSIBILITY_ROLES:
+                raise ValueError("unsupported responsibility role")
+        starts_at = _time(body.get("starts_at"), "starts_at")
+        ends_at = (
+            _time(body.get("ends_at"), "ends_at")
+            if body.get("ends_at") is not None
+            else None
+        )
+        if delegation_type == "temporary":
+            if ends_at is None or ends_at <= starts_at:
+                raise ValueError("temporary delegation requires a later end time")
+            if (ends_at - starts_at).days > 366:
+                raise ValueError("temporary delegation exceeds 366 days")
+            if scope_type is not None:
+                policies = self.repository.policies_for_chain(
+                    self._chain(scope_type, scope_uid)
+                )
+                limits = [
+                    int(item["definition"]["max_delegation_days"])
+                    for item in policies
+                    if item["policy_type"] == "central_policy"
+                ]
+                if limits and (ends_at - starts_at).total_seconds() > min(limits) * 86400:
+                    raise ValueError("temporary delegation exceeds central policy limit")
+        elif ends_at is not None:
+            raise ValueError("departure transfer cannot have an end time")
+        required_users = [delegate] + ([source] if delegation_type == "temporary" else [])
+        if self.repository.users_available(required_users) != set(required_users):
+            raise ValueError("delegation user is unknown or disabled")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "source_user_uid": source,
+            "delegate_user_uid": delegate,
+            "scope_type": scope_type,
+            "scope_uid": scope_uid,
+            "responsibility_role": role,
+            "delegation_type": delegation_type,
+            "starts_at": starts_at.isoformat(),
+            "ends_at": ends_at.isoformat() if ends_at else None,
+            "reason": _string(body.get("reason"), "reason", 500),
+            "status": "active",
+            "current_version": 1,
+            "created_by": _uid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "updated_by": _uid(actor_uid, "actor_uid"),
+            "updated_at": now,
+        }
+        try:
+            result = self.repository.create_delegation(record)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def transfer_departing_user(
+        self, payload: Any, *, actor_uid: str
+    ) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {
+                "source_user_uid",
+                "delegate_user_uid",
+                "scope_type",
+                "scope_uid",
+                "responsibility_role",
+                "reason",
+            },
+            "departure transfer",
+        )
+        return self.create_delegation(
+            {
+                **body,
+                "responsibility_role": body.get("responsibility_role"),
+                "delegation_type": "departure_transfer",
+                "starts_at": self.now_factory().isoformat(),
+                "ends_at": None,
+            },
+            actor_uid=actor_uid,
+        )
+
+    def revoke_delegation(
+        self, uid: str, *, expected_version: int, actor_uid: str
+    ) -> dict[str, Any]:
+        record = self.repository.get_delegation(_uid(uid, "delegation_uid"))
+        if record is None:
+            raise LookupError("delegation was not found")
+        if record["status"] != "active":
+            raise RuntimeError("delegation is not active")
+        record.update(
+            {
+                "status": "revoked",
+                "updated_by": _uid(actor_uid, "actor_uid"),
+                "updated_at": self.now_factory().isoformat(),
+            }
+        )
+        try:
+            result = self.repository.update_delegation(record, int(expected_version))
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def expire_delegations(
+        self, *, at: datetime | str | None = None, actor_uid: str
+    ) -> list[dict[str, Any]]:
+        timestamp = _time(at, "at") if at is not None else self.now_factory()
+        try:
+            result = self.repository.expire_delegations(
+                timestamp, _uid(actor_uid, "actor_uid")
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_delegations(self, *, status: str | None = None) -> list[dict[str, Any]]:
+        normalized = None
+        if status is not None:
+            normalized = _string(status, "status", 20)
+            if normalized not in {"active", "revoked", "expired"}:
+                raise ValueError("unsupported delegation status")
+        return self.repository.list_delegations(status=normalized)
+
+    def create_policy(self, payload: Any, *, actor_uid: str) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {
+                "code",
+                "name",
+                "policy_type",
+                "scope_type",
+                "scope_uid",
+                "definition",
+            },
+            "responsibility policy",
+        )
+        code = _string(body.get("code"), "code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("responsibility policy code is invalid")
+        policy_type = _string(body.get("policy_type"), "policy_type", 30)
+        if policy_type not in POLICY_TYPES:
+            raise ValueError("unsupported responsibility policy type")
+        actor = _uid(actor_uid, "actor_uid")
+        now = self.now_factory().isoformat()
+        policy_uid = self.uid_factory()
+        version_uid = self.uid_factory()
+        policy = {
+            "uid": policy_uid,
+            "code": code,
+            "name": _string(body.get("name"), "name", 300),
+            "policy_type": policy_type,
+            "scope_type": _resource_type(body.get("scope_type"), "scope_type"),
+            "scope_uid": _string(body.get("scope_uid"), "scope_uid", 120),
+            "status": "draft",
+            "current_version": 1,
+            "active_version_uid": None,
+            "created_by": actor,
+            "created_at": now,
+            "updated_at": now,
+        }
+        version = {
+            "uid": version_uid,
+            "policy_uid": policy_uid,
+            "version": 1,
+            "status": "draft",
+            "definition": _policy_definition(policy_type, body.get("definition")),
+            "created_by": actor,
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        try:
+            self.repository.create_policy(policy, version)
+            self.commit()
+            return {**policy, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_policies(self) -> list[dict[str, Any]]:
+        return self.repository.list_policies()
+
+    def revise_policy(
+        self,
+        policy_uid: str,
+        definition: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(policy_uid, "policy_uid")
+        policy = self.repository.get_policy(uid)
+        if policy is None:
+            raise LookupError("responsibility policy was not found")
+        if int(policy["current_version"]) != int(expected_version):
+            raise RuntimeError("policy version conflict")
+        now = self.now_factory().isoformat()
+        version_number = int(expected_version) + 1
+        version = {
+            "uid": self.uid_factory(),
+            "policy_uid": uid,
+            "version": version_number,
+            "status": "draft",
+            "definition": _policy_definition(policy["policy_type"], definition),
+            "created_by": _uid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        updated = {
+            **policy,
+            "current_version": version_number,
+            "updated_at": now,
+        }
+        try:
+            self.repository.revise_policy(updated, version, int(expected_version))
+            self.commit()
+            return {**updated, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def publish_policy(
+        self,
+        policy_uid: str,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(policy_uid, "policy_uid")
+        policy = self.repository.get_policy(uid)
+        if policy is None:
+            raise LookupError("responsibility policy was not found")
+        if int(policy["current_version"]) != int(expected_version):
+            raise RuntimeError("policy version conflict")
+        version = self.repository.policy_version(uid, int(expected_version))
+        if version is None or version["status"] != "draft":
+            raise RuntimeError("responsibility policy version is not publishable")
+        actor = _uid(actor_uid, "actor_uid")
+        now = self.now_factory().isoformat()
+        published_version = {
+            **version,
+            "status": "published",
+            "published_by": actor,
+            "published_at": now,
+        }
+        published = {
+            **policy,
+            "status": "published",
+            "active_version_uid": version["uid"],
+            "updated_at": now,
+        }
+        try:
+            self.repository.publish_policy(
+                published, published_version, int(expected_version)
+            )
+            self.commit()
+            return {**published, "active_version": published_version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def evaluate_joint_review(
+        self,
+        resource_type: str,
+        resource_uid: str,
+        decisions: Any,
+    ) -> dict[str, Any]:
+        resolved = self.resolve(resource_type, resource_uid)
+        policy = resolved.get("joint_review")
+        if policy is None:
+            raise LookupError("joint review policy was not found")
+        if not isinstance(decisions, list):
+            raise ValueError("joint review decisions must be an array")
+        by_domain = {}
+        seen_users = set()
+        for raw in decisions:
+            item = _closed(
+                raw,
+                {"user_uid", "domain_uid", "decision"},
+                "joint review decision",
+            )
+            user_uid = _uid(item.get("user_uid"), "user_uid")
+            domain_uid = _string(item.get("domain_uid"), "domain_uid", 120)
+            decision = _string(item.get("decision"), "decision", 20)
+            if decision not in DECISIONS:
+                raise ValueError("unsupported joint review decision")
+            if domain_uid not in policy["required_domains"]:
+                raise ValueError("review domain is not eligible")
+            domain = self.resolve("business_domain", domain_uid)
+            eligible_users = {
+                owner["effective_user_id"] for owner in domain["final_owners"]
+            }
+            if user_uid not in eligible_users:
+                raise ValueError("reviewer is not the final owner of the domain")
+            if user_uid in seen_users or domain_uid in by_domain:
+                raise ValueError("joint review decisions must be independent")
+            seen_users.add(user_uid)
+            by_domain[domain_uid] = decision
+        approved_domains = sorted(
+            domain for domain, decision in by_domain.items() if decision == "approve"
+        )
+        missing = sorted(set(policy["required_domains"]) - set(approved_domains))
+        if "reject" in by_domain.values():
+            status = "rejected"
+        elif len(approved_domains) < policy["min_approvals"] or (
+            policy["require_all_domains"] and missing
+        ):
+            status = "pending"
+        else:
+            status = "approved"
+        return {
+            "status": status,
+            "policy_uid": policy["policy_uid"],
+            "approval_count": len(approved_domains),
+            "approved_domains": approved_domains,
+            "missing_domains": missing,
+            "deterministic": True,
+        }
+
+    def operations(self, owner_uid: str) -> dict[str, Any]:
+        owner = _uid(owner_uid, "owner_uid")
+        result = self.repository.responsibility_operations(owner)
+        tasks = result.get("tasks") or []
+        metrics = result.get("metrics") or []
+        return {
+            "owner_uid": owner,
+            "tasks": tasks,
+            "metrics": metrics,
+            "summary": {
+                "task_count": len(tasks),
+                "metric_count": len(metrics),
+                "overdue_count": sum(bool(item.get("overdue")) for item in tasks),
+                "recurrent_count": sum(
+                    int(item.get("recurrence_count") or 0) > 1 for item in tasks
+                ),
+            },
+            "grants_data_access": False,
+        }

+ 635 - 0
deployment/app/core/governance/unified_responsibility_repository.py

@@ -0,0 +1,635 @@
+"""PostgreSQL persistence for unified governance responsibilities."""
+
+from __future__ import annotations
+
+import copy
+import json
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.governance.responsibilities import SqlAlchemyResponsibilityRepository
+
+
+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 key.endswith("uid") or key in {"uid", "created_by", "updated_by"}:
+            result[key] = str(value)
+    return result
+
+
+class SqlAlchemyUnifiedResponsibilityRepository:
+    def __init__(self, session):
+        self.session = session
+        self.matrix_repository = SqlAlchemyResponsibilityRepository(session)
+
+    def get(self, resource_type: str, resource_uid: str) -> dict[str, Any]:
+        return self.matrix_repository.get(resource_type, resource_uid)
+
+    def parent(self, resource_type: str, resource_uid: str):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, resource_type, resource_uid,
+                           parent_type, parent_uid, revision,
+                           updated_by::text AS updated_by, updated_at
+                    FROM public.governance_responsibility_hierarchy
+                    WHERE resource_type = :resource_type
+                      AND resource_uid = :resource_uid
+                    """
+                ),
+                {"resource_type": resource_type, "resource_uid": resource_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def _audit(
+        self,
+        *,
+        resource_type: str,
+        resource_uid: str,
+        actor_uid: str,
+        action: str,
+        before: Any,
+        after: Any,
+    ) -> None:
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_audit_events (
+                    resource_type, resource_uid, actor_uid, action,
+                    before_state, after_state
+                ) VALUES (
+                    :resource_type, :resource_uid, CAST(:actor_uid AS uuid),
+                    :action, CAST(:before AS jsonb), CAST(:after AS jsonb)
+                )
+                """
+            ),
+            {
+                "resource_type": resource_type,
+                "resource_uid": resource_uid,
+                "actor_uid": actor_uid,
+                "action": action,
+                "before": json.dumps(before, ensure_ascii=False),
+                "after": json.dumps(after, ensure_ascii=False),
+            },
+        )
+
+    def set_parent(self, record: dict[str, Any], expected_revision: int):
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext('responsibility-hierarchy'))")
+        )
+        current = self.parent(record["resource_type"], record["resource_uid"])
+        current_revision = int(current["revision"]) if current else 0
+        if current_revision != expected_revision:
+            raise RuntimeError("hierarchy revision conflict")
+        cycle = self.session.execute(
+            text(
+                """
+                WITH RECURSIVE ancestors(resource_type, resource_uid, depth) AS (
+                    SELECT CAST(:parent_type AS VARCHAR(40)),
+                           CAST(:parent_uid AS VARCHAR(120)), 0
+                    UNION ALL
+                    SELECT h.parent_type, h.parent_uid, a.depth + 1
+                    FROM ancestors a
+                    JOIN public.governance_responsibility_hierarchy h
+                      ON h.resource_type = a.resource_type
+                     AND h.resource_uid = a.resource_uid
+                    WHERE a.depth < 20
+                )
+                SELECT EXISTS (
+                    SELECT 1 FROM ancestors
+                    WHERE resource_type = :resource_type
+                      AND resource_uid = :resource_uid
+                )
+                """
+            ),
+            record,
+        ).scalar_one()
+        if cycle:
+            raise ValueError("responsibility hierarchy contains a cycle")
+        revision = current_revision + 1
+        uid = current["uid"] if current else new_governance_uid()
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_hierarchy (
+                    uid, resource_type, resource_uid, parent_type, parent_uid,
+                    revision, updated_by
+                ) VALUES (
+                    CAST(:uid AS uuid), :resource_type, :resource_uid,
+                    :parent_type, :parent_uid, :revision,
+                    CAST(:updated_by AS uuid)
+                )
+                ON CONFLICT (resource_type, resource_uid) DO UPDATE SET
+                    parent_type = EXCLUDED.parent_type,
+                    parent_uid = EXCLUDED.parent_uid,
+                    revision = EXCLUDED.revision,
+                    updated_by = EXCLUDED.updated_by,
+                    updated_at = CURRENT_TIMESTAMP
+                """
+            ),
+            {**record, "uid": uid, "revision": revision},
+        )
+        saved = self.parent(record["resource_type"], record["resource_uid"])
+        self._audit(
+            resource_type=record["resource_type"],
+            resource_uid=record["resource_uid"],
+            actor_uid=record["updated_by"],
+            action="hierarchy_replaced",
+            before=current or {},
+            after=saved,
+        )
+        return saved
+
+    def users_available(self, user_uids) -> set[str]:
+        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(:user_uids)
+                """
+            ),
+            {"user_uids": values},
+        )
+        return {str(row[0]) for row in rows}
+
+    def active_delegation(
+        self,
+        source_user_uid: str,
+        responsibility_role: str,
+        chain: list[dict[str, Any]],
+        at: datetime,
+    ):
+        rows = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid,
+                           source_user_uid::text AS source_user_uid,
+                           delegate_user_uid::text AS delegate_user_uid,
+                           scope_type, scope_uid, responsibility_role,
+                           delegation_type, starts_at, ends_at, reason,
+                           status, current_version,
+                           created_by::text AS created_by,
+                           updated_by::text AS updated_by,
+                           created_at, updated_at
+                    FROM public.governance_responsibility_delegations
+                    WHERE source_user_uid = CAST(:source_user_uid AS uuid)
+                      AND status = 'active'
+                      AND starts_at <= :evaluated_at
+                      AND (ends_at IS NULL OR ends_at > :evaluated_at)
+                      AND (
+                        responsibility_role IS NULL
+                        OR responsibility_role = :responsibility_role
+                      )
+                    """
+                ),
+                {
+                    "source_user_uid": source_user_uid,
+                    "responsibility_role": responsibility_role,
+                    "evaluated_at": at,
+                },
+            )
+            .mappings()
+            .all()
+        )
+        depths = {
+            (node["resource_type"], node["resource_uid"]): node["depth"]
+            for node in chain
+        }
+        candidates = []
+        for row in rows:
+            item = _plain(row)
+            scope = (item.get("scope_type"), item.get("scope_uid"))
+            if scope == (None, None):
+                depth = 999
+            elif scope in depths:
+                depth = depths[scope]
+            else:
+                continue
+            candidates.append(
+                (depth, 0 if item.get("responsibility_role") else 1, item)
+            )
+        return sorted(candidates, key=lambda value: value[:2])[0][2] if candidates else None
+
+    def create_delegation(self, record: dict[str, Any]):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_delegations (
+                    uid, source_user_uid, delegate_user_uid, scope_type,
+                    scope_uid, responsibility_role, delegation_type,
+                    starts_at, ends_at, reason, status, current_version,
+                    created_by, updated_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:source_user_uid AS uuid),
+                    CAST(:delegate_user_uid AS uuid), :scope_type, :scope_uid,
+                    :responsibility_role, :delegation_type, :starts_at,
+                    :ends_at, :reason, :status, :current_version,
+                    CAST(:created_by AS uuid), CAST(:updated_by AS uuid),
+                    :created_at, :updated_at
+                )
+                """
+            ),
+            record,
+        )
+        self._audit(
+            resource_type=record.get("scope_type") or "organization",
+            resource_uid=record.get("scope_uid") or "*",
+            actor_uid=record["created_by"],
+            action="delegation_created",
+            before={},
+            after=record,
+        )
+        return copy.deepcopy(record)
+
+    def get_delegation(self, uid: str):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid,
+                           source_user_uid::text AS source_user_uid,
+                           delegate_user_uid::text AS delegate_user_uid,
+                           scope_type, scope_uid, responsibility_role,
+                           delegation_type, starts_at, ends_at, reason,
+                           status, current_version,
+                           created_by::text AS created_by,
+                           updated_by::text AS updated_by,
+                           created_at, updated_at
+                    FROM public.governance_responsibility_delegations
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def list_delegations(self, *, status: str | None = None):
+        where = "WHERE status = :status" if status else ""
+        rows = (
+            self.session.execute(
+                text(
+                    f"""
+                    SELECT uid::text AS uid,
+                           source_user_uid::text AS source_user_uid,
+                           delegate_user_uid::text AS delegate_user_uid,
+                           scope_type, scope_uid, responsibility_role,
+                           delegation_type, starts_at, ends_at, reason,
+                           status, current_version,
+                           created_by::text AS created_by,
+                           updated_by::text AS updated_by,
+                           created_at, updated_at
+                    FROM public.governance_responsibility_delegations
+                    {where}
+                    ORDER BY created_at DESC, uid DESC
+                    """
+                ),
+                {"status": status} if status else {},
+            )
+            .mappings()
+            .all()
+        )
+        return [_plain(row) for row in rows]
+
+    def update_delegation(self, record: dict[str, Any], expected_version: int):
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_delegations
+                SET status = :status,
+                    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 result.rowcount != 1:
+            raise RuntimeError("delegation version conflict")
+        saved = self.get_delegation(record["uid"])
+        self._audit(
+            resource_type=record.get("scope_type") or "organization",
+            resource_uid=record.get("scope_uid") or "*",
+            actor_uid=record["updated_by"],
+            action=f"delegation_{record['status']}",
+            before=record,
+            after=saved,
+        )
+        return saved
+
+    def expire_delegations(self, at: datetime, actor_uid: str):
+        candidates = [
+            item
+            for item in self.list_delegations(status="active")
+            if item.get("ends_at")
+            and datetime.fromisoformat(item["ends_at"]) <= at
+        ]
+        return [
+            self.update_delegation(
+                {**item, "status": "expired", "updated_by": actor_uid, "updated_at": at.isoformat()},
+                int(item["current_version"]),
+            )
+            for item in candidates
+        ]
+
+    def create_policy(self, policy: dict[str, Any], version: dict[str, Any]):
+        self._insert_policy(policy)
+        self._insert_policy_version(version)
+        self._audit_policy("policy_created", policy, {}, {**policy, "version": version})
+        return copy.deepcopy(policy)
+
+    def _insert_policy(self, policy: dict[str, Any]):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_policies (
+                    uid, code, name, policy_type, scope_type, scope_uid,
+                    status, current_version, active_version_uid,
+                    created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :name, :policy_type,
+                    :scope_type, :scope_uid, :status, :current_version,
+                    CAST(:active_version_uid AS uuid), CAST(:created_by AS uuid),
+                    :created_at, :updated_at
+                )
+                """
+            ),
+            policy,
+        )
+
+    def _insert_policy_version(self, version: dict[str, Any]):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_policy_versions (
+                    uid, policy_uid, version, status, definition,
+                    created_by, created_at, published_by, published_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:policy_uid AS uuid), :version,
+                    :status, CAST(:definition 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)},
+        )
+
+    def _audit_policy(self, action, policy, before, after):
+        version = after.get("version") if isinstance(after, dict) else None
+        version = version if isinstance(version, dict) else {}
+        self._audit(
+            resource_type="responsibility_policy",
+            resource_uid=policy["uid"],
+            actor_uid=(after.get("published_by") if isinstance(after, dict) else None)
+            or version.get("published_by")
+            or version.get("created_by")
+            or policy["created_by"],
+            action=action,
+            before=before,
+            after=after,
+        )
+
+    def get_policy(self, uid: str):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, code, name, policy_type,
+                           scope_type, scope_uid, status, current_version,
+                           active_version_uid::text AS active_version_uid,
+                           created_by::text AS created_by, created_at, updated_at
+                    FROM public.governance_responsibility_policies
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def list_policies(self):
+        rows = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT p.uid::text AS uid, p.code, p.name, p.policy_type,
+                           p.scope_type, p.scope_uid, p.status,
+                           p.current_version,
+                           p.active_version_uid::text AS active_version_uid,
+                           p.created_by::text AS created_by,
+                           p.created_at, p.updated_at,
+                           v.definition AS active_definition
+                    FROM public.governance_responsibility_policies p
+                    LEFT JOIN public.governance_responsibility_policy_versions v
+                      ON v.uid = p.active_version_uid
+                    ORDER BY p.updated_at DESC, p.uid DESC
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        )
+        return [_plain(row) for row in rows]
+
+    def policy_version(self, policy_uid: str, version: int):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, policy_uid::text AS policy_uid,
+                           version, status, definition,
+                           created_by::text AS created_by, created_at,
+                           published_by::text AS published_by, published_at
+                    FROM public.governance_responsibility_policy_versions
+                    WHERE policy_uid = CAST(:policy_uid AS uuid)
+                      AND version = :version
+                    """
+                ),
+                {"policy_uid": policy_uid, "version": version},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def revise_policy(self, policy, version, expected_version):
+        current = self.get_policy(policy["uid"])
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_policies
+                SET status = :status, current_version = :current_version,
+                    active_version_uid = CAST(:active_version_uid AS uuid),
+                    updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :expected_version
+                """
+            ),
+            {**policy, "expected_version": expected_version},
+        )
+        if result.rowcount != 1:
+            raise RuntimeError("policy version conflict")
+        self._insert_policy_version(version)
+        saved = self.get_policy(policy["uid"])
+        self._audit_policy("policy_revised", policy, current, {**saved, "version": version})
+        return saved
+
+    def publish_policy(self, policy, version, expected_version):
+        current = self.get_policy(policy["uid"])
+        self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_policy_versions
+                SET status = 'superseded'
+                WHERE policy_uid = CAST(:policy_uid AS uuid)
+                  AND status = 'published'
+                """
+            ),
+            {"policy_uid": policy["uid"]},
+        )
+        version_result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_policy_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,
+        )
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_policies
+                SET status = 'published',
+                    active_version_uid = CAST(:active_version_uid AS uuid),
+                    updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :expected_version
+                """
+            ),
+            {**policy, "expected_version": expected_version},
+        )
+        if version_result.rowcount != 1 or result.rowcount != 1:
+            raise RuntimeError("policy version conflict")
+        saved = self.get_policy(policy["uid"])
+        self._audit_policy("policy_published", policy, current, {**saved, "version": version})
+        return saved
+
+    def policies_for_chain(self, chain: list[dict[str, Any]]):
+        result = []
+        for node in chain:
+            rows = (
+                self.session.execute(
+                    text(
+                        """
+                        SELECT p.uid::text AS uid, p.code, p.name,
+                               p.policy_type, p.scope_type, p.scope_uid,
+                               p.status, p.current_version,
+                               p.active_version_uid::text AS active_version_uid,
+                               p.created_by::text AS created_by,
+                               v.definition
+                        FROM public.governance_responsibility_policies p
+                        JOIN public.governance_responsibility_policy_versions v
+                          ON v.uid = p.active_version_uid
+                        WHERE p.status = 'published'
+                          AND p.scope_type = :scope_type
+                          AND p.scope_uid = :scope_uid
+                        ORDER BY p.policy_type, p.code
+                        """
+                    ),
+                    {"scope_type": node["resource_type"], "scope_uid": node["resource_uid"]},
+                )
+                .mappings()
+                .all()
+            )
+            result.extend({**_plain(row), "inheritance_depth": node["depth"]} for row in rows)
+        return result
+
+    def responsibility_operations(self, owner_uid: str):
+        tasks = []
+        quality_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, issue_code AS code, message AS title,
+                       status, priority, due_at,
+                       (due_at IS NOT NULL AND due_at < CURRENT_TIMESTAMP
+                        AND status <> 'closed') AS overdue,
+                       occurrence_number AS recurrence_count
+                FROM public.device_quality_issues
+                WHERE assignee_uid = CAST(:owner_uid AS uuid)
+                  AND status <> 'closed'
+                ORDER BY due_at NULLS LAST, created_at DESC
+                """
+            ),
+            {"owner_uid": owner_uid},
+        ).mappings()
+        tasks.extend({"kind": "quality_issue", **_plain(row)} for row in quality_rows)
+        incident_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, code, title, status, severity,
+                       FALSE AS overdue, 1 AS recurrence_count
+                FROM public.data_incidents
+                WHERE owner_uid = CAST(:owner_uid AS uuid)
+                  AND status <> 'closed'
+                ORDER BY updated_at DESC
+                """
+            ),
+            {"owner_uid": owner_uid},
+        ).mappings()
+        tasks.extend({"kind": "data_incident", **_plain(row)} for row in incident_rows)
+        correction_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, '元数据纠正' AS title, status,
+                       FALSE AS overdue, 1 AS recurrence_count
+                FROM public.active_metadata_corrections
+                WHERE assignee_uid = CAST(:owner_uid AS uuid)
+                  AND status = 'pending'
+                ORDER BY updated_at DESC
+                """
+            ),
+            {"owner_uid": owner_uid},
+        ).mappings()
+        tasks.extend({"kind": "metadata_correction", **_plain(row)} for row in correction_rows)
+        metric_rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, code, name, sli_type, scope_type,
+                       scope_uid, operator, target, window_seconds, status
+                FROM public.data_slo_policies
+                WHERE owner_uid = CAST(:owner_uid AS uuid)
+                  AND status = 'active'
+                ORDER BY code
+                """
+            ),
+            {"owner_uid": owner_uid},
+        ).mappings()
+        metrics = [{"kind": "slo", **_plain(row)} for row in metric_rows]
+        return {"tasks": tasks, "metrics": metrics}

+ 198 - 0
deployment/migrations/versions/20260801_420_unified_responsibilities.py

@@ -0,0 +1,198 @@
+"""Add hierarchical, delegated and policy-driven governance responsibility."""
+
+from alembic import op
+
+
+revision = "20260801_420"
+down_revision = "20260731_410"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        ALTER TABLE public.governance_responsibility_scopes
+            DROP CONSTRAINT IF EXISTS
+                governance_responsibility_scopes_resource_type_check;
+        ALTER TABLE public.governance_responsibility_scopes
+            ADD CONSTRAINT
+                governance_responsibility_scopes_resource_type_check
+            CHECK (
+                resource_type IN (
+                    'business_domain','device_asset','device_ontology',
+                    'device_mapping','device_quality','fault_classification',
+                    'quality_issue','organization','data_asset','semantic_term',
+                    'data_standard','quality_policy','data_product','agent'
+                )
+            ) NOT VALID;
+        ALTER TABLE public.governance_responsibility_scopes
+            VALIDATE CONSTRAINT
+                governance_responsibility_scopes_resource_type_check;
+
+        ALTER TABLE public.governance_responsibility_assignments
+            DROP CONSTRAINT IF EXISTS
+                governance_responsibility_assignments_responsibility_role_check;
+        ALTER TABLE public.governance_responsibility_assignments
+            ADD CONSTRAINT
+                governance_responsibility_assignments_responsibility_role_check
+            CHECK (
+                responsibility_role IN (
+                    'organization_owner','domain_owner','data_steward',
+                    'data_architect','asset_manager','term_steward',
+                    'standard_owner','quality_owner','product_owner','agent_owner'
+                )
+            ) NOT VALID;
+        ALTER TABLE public.governance_responsibility_assignments
+            VALIDATE CONSTRAINT
+                governance_responsibility_assignments_responsibility_role_check;
+
+        CREATE TABLE public.governance_responsibility_hierarchy (
+            uid UUID PRIMARY KEY,
+            resource_type VARCHAR(40) NOT NULL,
+            resource_uid VARCHAR(120) NOT NULL,
+            parent_type VARCHAR(40) NOT NULL,
+            parent_uid VARCHAR(120) NOT NULL,
+            revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (resource_type, resource_uid),
+            CHECK (
+                resource_type IN (
+                    'organization','business_domain','data_asset','semantic_term',
+                    'data_standard','quality_policy','data_product','agent'
+                )
+            ),
+            CHECK (
+                parent_type IN (
+                    'organization','business_domain','data_asset','semantic_term',
+                    'data_standard','quality_policy','data_product','agent'
+                )
+            ),
+            CHECK (
+                resource_type <> parent_type OR resource_uid <> parent_uid
+            )
+        );
+        CREATE INDEX idx_governance_responsibility_hierarchy_parent
+            ON public.governance_responsibility_hierarchy(
+                parent_type, parent_uid
+            );
+
+        CREATE TABLE public.governance_responsibility_delegations (
+            uid UUID PRIMARY KEY,
+            source_user_uid UUID NOT NULL REFERENCES public.users(id),
+            delegate_user_uid UUID NOT NULL REFERENCES public.users(id),
+            scope_type VARCHAR(40),
+            scope_uid VARCHAR(120),
+            responsibility_role VARCHAR(40),
+            delegation_type VARCHAR(30) NOT NULL CHECK (
+                delegation_type IN ('temporary','departure_transfer')
+            ),
+            starts_at TIMESTAMPTZ NOT NULL,
+            ends_at TIMESTAMPTZ,
+            reason VARCHAR(500) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('active','revoked','expired')
+            ),
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (
+                current_version > 0
+            ),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (source_user_uid <> delegate_user_uid),
+            CHECK ((scope_type IS NULL) = (scope_uid IS NULL)),
+            CHECK (
+                scope_type IS NULL OR scope_type IN (
+                    'organization','business_domain','data_asset','semantic_term',
+                    'data_standard','quality_policy','data_product','agent'
+                )
+            ),
+            CHECK (
+                responsibility_role IS NULL OR responsibility_role IN (
+                    'organization_owner','domain_owner','data_steward',
+                    'data_architect','asset_manager','term_steward',
+                    'standard_owner','quality_owner','product_owner','agent_owner'
+                )
+            ),
+            CHECK (
+                (delegation_type = 'temporary' AND ends_at > starts_at)
+                OR
+                (delegation_type = 'departure_transfer' AND ends_at IS NULL)
+            )
+        );
+        CREATE UNIQUE INDEX uq_governance_responsibility_active_delegation
+            ON public.governance_responsibility_delegations(
+                source_user_uid,
+                COALESCE(scope_type, ''), COALESCE(scope_uid, ''),
+                COALESCE(responsibility_role, '')
+            ) WHERE status = 'active';
+        CREATE INDEX idx_governance_responsibility_delegate
+            ON public.governance_responsibility_delegations(
+                delegate_user_uid, status, ends_at
+            );
+
+        CREATE TABLE public.governance_responsibility_policies (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            policy_type VARCHAR(30) NOT NULL CHECK (
+                policy_type IN ('central_policy','joint_review')
+            ),
+            scope_type VARCHAR(40) NOT NULL CHECK (
+                scope_type IN (
+                    'organization','business_domain','data_asset','semantic_term',
+                    'data_standard','quality_policy','data_product','agent'
+                )
+            ),
+            scope_uid VARCHAR(120) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','retired')
+            ),
+            current_version INTEGER NOT NULL DEFAULT 1 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_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.governance_responsibility_policy_versions (
+            uid UUID PRIMARY KEY,
+            policy_uid UUID NOT NULL
+                REFERENCES public.governance_responsibility_policies(uid),
+            version INTEGER NOT NULL CHECK (version > 0),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','superseded')
+            ),
+            definition 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 (policy_uid, version),
+            CHECK (jsonb_typeof(definition) = 'object')
+        );
+        ALTER TABLE public.governance_responsibility_policies
+            ADD CONSTRAINT governance_responsibility_active_version_fk
+            FOREIGN KEY (active_version_uid)
+            REFERENCES public.governance_responsibility_policy_versions(uid);
+        CREATE UNIQUE INDEX uq_governance_responsibility_published_version
+            ON public.governance_responsibility_policy_versions(policy_uid)
+            WHERE status = 'published';
+        CREATE INDEX idx_governance_responsibility_policy_scope
+            ON public.governance_responsibility_policies(
+                scope_type, scope_uid, status
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "responsibility delegation and policy evidence is retained; "
+        "downgrade requires an approved archival migration"
+    )

+ 11 - 5
docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md

@@ -329,11 +329,11 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 
 **主要工作:**
 
-- [ ] 支持组织、业务域和对象层级的责任继承与显式覆盖。
-- [ ] 支持代理、委派、到期收回和离岗转交。
-- [ ] 支持跨域联合评审和中央策略下发。
-- [ ] 将治理任务、指标、逾期和复发关联到责任人。
-- [ ] 保持权限与责任分离:有责任不自动获得数据访问权限。
+- [x] 支持组织、业务域和对象层级的责任继承与显式覆盖。
+- [x] 支持代理、委派、到期收回和离岗转交。
+- [x] 支持跨域联合评审和中央策略下发。
+- [x] 将治理任务、指标、逾期和复发关联到责任人。
+- [x] 保持权限与责任分离:有责任不自动获得数据访问权限。
 
 **主要文件区域:**
 
@@ -346,6 +346,12 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 **完成门禁:** 六类治理对象均可计算唯一最终负责人;继承、覆盖、委派和失效过程
 有版本、并发控制和审计。
 
+**工程状态:** 已完成本地工程门禁。六类对象可按组织、业务域和对象层级解析唯一
+最终负责人;显式覆盖、临时委派、到期收回、离岗转交、中央策略、跨域联合评审和
+责任运营汇总均具备版本与审计。正式组织层级、真实责任人、离岗身份源及企业联合
+评审策略仍需环境绑定与 UAT,当前不等同生产就绪。详见
+`docs/phase2/P2_WP06_UNIFIED_RESPONSIBILITIES.md`。
+
 ### P2-WP07 统一审批、任务与通知
 
 **目标:** 用统一流程承载质量、治理、发布和高风险动作。

+ 6 - 2
docs/architecture/DATA_MODEL.md

@@ -126,9 +126,13 @@ flowchart LR
 | `users` | `id UUID`, `username`, `password_hash`, `status` | 替换旧明文/可逆密码字段 |
 | `roles` | `id`, `code` | 固定 `admin/editor/viewer`,即管理员、编辑者、查看者 |
 | `user_roles` | `user_id`, `role_id` | 用户与角色映射 |
-| `governance_responsibility_scopes` | `resource_type`, `resource_uid`, `revision`, `updated_by` | 业务域、设备、本体、映射、故障分类和质量问题的责任矩阵版本 |
-| `governance_responsibility_assignments` | `scope_id`, `user_id`, `responsibility_role`, `raci_role` | Owner、Steward、架构师、设备资产管理员与 RACI 责任绑定 |
+| `governance_responsibility_scopes` | `resource_type`, `resource_uid`, `revision`, `updated_by` | 组织、业务域、设备及资产、术语、标准、质量、产品、Agent 的责任矩阵版本 |
+| `governance_responsibility_assignments` | `scope_id`, `user_id`, `responsibility_role`, `raci_role` | 各类 Owner、Steward、架构师、设备资产管理员与 RACI 责任绑定 |
 | `governance_responsibility_audit_events` | `resource_type`, `resource_uid`, `actor_uid`, `before_state`, `after_state` | 责任矩阵变更前后快照与操作审计 |
+| `governance_responsibility_hierarchy` | `resource_type`, `resource_uid`, `parent_type`, `parent_uid`, `revision` | 组织、业务域和对象层级的责任继承关系与乐观并发版本 |
+| `governance_responsibility_delegations` | `source_user_uid`, `delegate_user_uid`, `scope_type`, `responsibility_role`, `delegation_type`, `ends_at`, `current_version` | 临时委派、到期收回、主动收回与离岗转交当前态 |
+| `governance_responsibility_policies` | `policy_type`, `scope_type`, `scope_uid`, `current_version`, `active_version_uid` | 中央责任策略和跨域联合评审策略当前态 |
+| `governance_responsibility_policy_versions` | `policy_uid`, `version`, `definition`, `status`, `published_by` | 不可变责任策略版本和发布证据 |
 | `dataflow_workflow_versions` | `id`, `dataflow_uid`, `environment`, `version_no`, `n8n_workflow_id`, `status` | 一个 DataFlow 对多个 n8n Workflow 版本 |
 | `governance_documents` | `object_type`, `object_uid`, `object_version`, `content_hash` | 治理对象文本快照 |
 | `governance_chunks` | `document_id`, `chunk_no`, `content`, `embedding vector` | Qwen Embedding 结果 |

+ 360 - 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: 301
+x-route-count: 314
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -7540,6 +7540,298 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/responsibilities/delegations":
+    get:
+      tags: [system]
+      operationId: system_list_responsibility_delegations_get
+      summary: "list responsibility delegations"
+      x-source: "app/api/system/responsibilities.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [system]
+      operationId: system_create_responsibility_delegation_post
+      summary: "create responsibility delegation"
+      x-source: "app/api/system/responsibilities.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/system/responsibilities/delegations/expire":
+    post:
+      tags: [system]
+      operationId: system_expire_responsibility_delegations_post
+      summary: "expire responsibility delegations"
+      x-source: "app/api/system/responsibilities.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/system/responsibilities/delegations/{delegation_uid}/revoke":
+    post:
+      tags: [system]
+      operationId: system_revoke_responsibility_delegation_post
+      summary: "revoke responsibility delegation"
+      x-source: "app/api/system/responsibilities.py"
+      parameters:
+        - name: delegation_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/system/responsibilities/hierarchy/{resource_type}/{resource_uid}":
+    put:
+      tags: [system]
+      operationId: system_replace_responsibility_parent_put
+      summary: "replace responsibility parent"
+      x-source: "app/api/system/responsibilities.py"
+      parameters:
+        - name: resource_type
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: resource_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/system/responsibilities/operations":
+    get:
+      tags: [system]
+      operationId: system_responsibility_operations_get
+      summary: "responsibility operations"
+      x-source: "app/api/system/responsibilities.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/responsibilities/policies":
+    get:
+      tags: [system]
+      operationId: system_list_responsibility_policies_get
+      summary: "list responsibility policies"
+      x-source: "app/api/system/responsibilities.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [system]
+      operationId: system_create_responsibility_policy_post
+      summary: "create responsibility policy"
+      x-source: "app/api/system/responsibilities.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/system/responsibilities/policies/{policy_uid}/publish":
+    post:
+      tags: [system]
+      operationId: system_publish_responsibility_policy_post
+      summary: "publish responsibility policy"
+      x-source: "app/api/system/responsibilities.py"
+      parameters:
+        - name: policy_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/system/responsibilities/policies/{policy_uid}/revisions":
+    post:
+      tags: [system]
+      operationId: system_revise_responsibility_policy_post
+      summary: "revise responsibility policy"
+      x-source: "app/api/system/responsibilities.py"
+      parameters:
+        - name: policy_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/system/responsibilities/transfers":
+    post:
+      tags: [system]
+      operationId: system_transfer_departing_responsibility_post
+      summary: "transfer departing responsibility"
+      x-source: "app/api/system/responsibilities.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/system/responsibilities/{resource_type}/{resource_uid}":
     get:
       tags: [system]
@@ -7606,6 +7898,73 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/responsibilities/{resource_type}/{resource_uid}/joint-review/evaluate":
+    post:
+      tags: [system]
+      operationId: system_evaluate_responsibility_joint_review_post
+      summary: "evaluate responsibility joint review"
+      x-source: "app/api/system/responsibilities.py"
+      parameters:
+        - name: resource_type
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: resource_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/system/responsibilities/{resource_type}/{resource_uid}/resolved":
+    get:
+      tags: [system]
+      operationId: system_resolve_responsibility_get
+      summary: "resolve responsibility"
+      x-source: "app/api/system/responsibilities.py"
+      parameters:
+        - name: resource_type
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: resource_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/system/translate":
     post:
       tags: [system]

+ 74 - 0
docs/phase2/P2_WP06_UNIFIED_RESPONSIBILITIES.md

@@ -0,0 +1,74 @@
+# P2-WP06 统一责任体系工程说明
+
+## 1. 完成范围
+
+P2-WP06 已完成本地工程实现和定向验证。平台在既有设备责任矩阵上扩展统一责任
+模型,覆盖数据资产、语义术语、数据标准、质量策略、数据产品和治理 Agent,并保留
+原有设备资产、本体、映射、质量和故障分类接口与数据。
+
+本工作包完成:
+
+- 组织、业务域、治理对象三级及更深层级的责任继承;
+- 对象级 RACI 显式覆盖,以及六类治理对象唯一最终负责人的确定性计算;
+- 临时委派、按范围/责任角色委派、到期收回、主动收回和离岗转交;
+- 中央责任策略的草稿、修订、发布和作用域下发;
+- 跨域联合评审的参与域、最小同意数、全域同意规则和确定性计算;
+- 质量问题、主动元数据纠正、数据事故和 SLO 指标的责任运营汇总;
+- 所有责任结果显式返回 `grants_data_access=false`,责任绑定不修改角色权限。
+
+P2-WP06 不创建审批实例、统一待办或通知送达。联合评审只提供策略合同与判定能力,
+由 P2-WP07 的统一审批和任务中心调用,避免提前建设第二套任务系统。
+
+## 2. 责任计算规则
+
+解析从治理对象向业务域和组织逐级查找,最多允许 20 层并拒绝循环。每个 RACI
+角色采用“最近一层显式配置优先”的覆盖规则;未配置的角色继续从上级继承。最终
+负责人只取当前有效的 `Accountable`:
+
+- 恰好一人:`resolved`;
+- 无有效负责人:`unresolved`;
+- 多名有效负责人:`ambiguous`。
+
+有效委派在责任解析后替换执行人,但保留原始责任人、来源层级、矩阵修订号、委派
+编号和委派类型。禁用用户不会成为有效最终负责人。中央策略可以要求责任角色完整、
+唯一 Accountable 和最长委派周期;解析结果同时给出策略合规状态。
+
+## 3. 并发、版本与审计
+
+数据库迁移 `20260801_420` 新增:
+
+- `governance_responsibility_hierarchy`:对象父级和乐观修订号;
+- `governance_responsibility_delegations`:委派/转交当前态和版本;
+- `governance_responsibility_policies`:策略身份、范围和活动版本;
+- `governance_responsibility_policy_versions`:不可变策略版本。
+
+责任矩阵继续使用既有 `governance_responsibility_scopes`、
+`governance_responsibility_assignments` 和
+`governance_responsibility_audit_events`。层级更新使用事务级互斥锁和 `If-Match`
+修订号;委派与策略使用当前版本条件更新。矩阵、层级、委派、收回、失效、转交、
+策略修订和发布均写入统一责任审计表。历史证据不允许普通降级删除。
+
+## 4. API 与管理台
+
+既有责任矩阵 GET/PUT API 保持不变,新增:
+
+- 最终责任解析和层级更新;
+- 委派列表、创建、收回、到期处理和离岗转交;
+- 策略列表、创建、修订和发布;
+- 联合评审确定性计算;
+- 按责任人查询任务、指标、逾期和复发汇总。
+
+统一责任中心提供“责任继承与覆盖、委派与离岗转交、中央策略与联合评审、责任运营
+视图”四个入口。读取沿用 `governance:responsibilities:read`,变更沿用
+`governance:responsibilities:manage`。责任人仍需单独获得目标数据和功能权限。
+
+## 5. 验收边界
+
+本地工程门禁覆盖六类对象、继承/覆盖、防环、版本冲突、临时委派到期、离岗转交、
+中央策略、联合评审、责任运营投影、权限分离、API 鉴权和前端契约。正式组织结构、
+真实责任人、企业级联合评审策略、离岗同步源和生产数据访问授权仍需在企业环境绑定
+并完成 UAT;本工作包完成不等同于生产就绪。
+
+验收使用隔离的全新 PostgreSQL 数据库从空库迁移到 `20260801_420`,并在真实约束下
+完成六类对象继承解析、临时委派与到期恢复、中央策略发布、双业务域联合评审和审计
+数量验证;测试数据及临时数据库在验证后移除。

+ 77 - 0
frontend/src/api/responsibilities.js

@@ -15,3 +15,80 @@ export function replaceResponsibilityMatrix (resourceType, resourceUid, assignme
     { headers: { 'If-Match': `"${revision}"` } }
   )
 }
+
+export function resolveResponsibility (resourceType, resourceUid, at) {
+  return http.get(`${resourcePath(resourceType, resourceUid)}/resolved`, {
+    params: at ? { at } : {}
+  })
+}
+
+export function replaceResponsibilityParent (resourceType, resourceUid, parent, revision) {
+  return http.put(
+    `/system/responsibilities/hierarchy/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceUid)}`,
+    parent,
+    { headers: { 'If-Match': `"${revision}"` } }
+  )
+}
+
+export function listResponsibilityDelegations (status) {
+  return http.get('/system/responsibilities/delegations', {
+    params: status ? { status } : {}
+  })
+}
+
+export function createResponsibilityDelegation (payload) {
+  return http.post('/system/responsibilities/delegations', payload)
+}
+
+export function revokeResponsibilityDelegation (uid, version) {
+  return http.post(
+    `/system/responsibilities/delegations/${encodeURIComponent(uid)}/revoke`,
+    {},
+    { headers: { 'If-Match': `"${version}"` } }
+  )
+}
+
+export function expireResponsibilityDelegations (at) {
+  return http.post('/system/responsibilities/delegations/expire', at ? { at } : {})
+}
+
+export function transferDepartingResponsibility (payload) {
+  return http.post('/system/responsibilities/transfers', payload)
+}
+
+export function listResponsibilityPolicies () {
+  return http.get('/system/responsibilities/policies')
+}
+
+export function createResponsibilityPolicy (payload) {
+  return http.post('/system/responsibilities/policies', payload)
+}
+
+export function reviseResponsibilityPolicy (uid, definition, version) {
+  return http.post(
+    `/system/responsibilities/policies/${encodeURIComponent(uid)}/revisions`,
+    { definition },
+    { headers: { 'If-Match': `"${version}"` } }
+  )
+}
+
+export function publishResponsibilityPolicy (uid, version) {
+  return http.post(
+    `/system/responsibilities/policies/${encodeURIComponent(uid)}/publish`,
+    {},
+    { headers: { 'If-Match': `"${version}"` } }
+  )
+}
+
+export function getResponsibilityOperations (ownerUid) {
+  return http.get('/system/responsibilities/operations', {
+    params: { owner_uid: ownerUid }
+  })
+}
+
+export function evaluateJointReview (resourceType, resourceUid, decisions) {
+  return http.post(
+    `${resourcePath(resourceType, resourceUid)}/joint-review/evaluate`,
+    { decisions }
+  )
+}

+ 248 - 237
frontend/src/views/systemManage/responsibility/index.vue

@@ -3,312 +3,323 @@
     <div class="d-flex align-start mb-5">
       <div>
         <div class="overline primary--text">GOVERNANCE ACCOUNTABILITY</div>
-        <h2 class="mb-1">设备责任矩阵</h2>
+        <h2 class="mb-1">统一责任中心</h2>
         <div class="text--secondary">
-          为设备台账、本体、跨系统映射和故障分类指定唯一最终审批
+          统一管理资产、术语、标准、质量、产品和 Agent 的最终责任
         </div>
       </div>
       <v-spacer />
-      <v-chip v-if="loaded" outlined color="primary">
-        修订 {{ revision }}
+      <v-chip outlined color="primary">
+        责任与权限分离
       </v-chip>
     </div>
 
     <v-alert text color="blue-grey" icon="mdi-shield-account-outline">
-      平台角色决定功能权限;责任矩阵决定用户对具体业务对象承担的职责。两者相互独立,
-      设备资产管理员必须同时拥有所需的平台操作权限
+      平台角色决定功能权限;责任矩阵决定用户对具体业务对象承担的职责。
+      被指定为负责人不自动获得数据访问权限,仍需通过原有权限体系授权
     </v-alert>
 
-    <v-card outlined class="mb-4">
-      <v-card-text>
-        <v-row align="center">
-          <v-col cols="12" md="4">
-            <v-select
-              v-model="resourceType"
-              :items="resourceTypeOptions"
-              item-text="text"
-              item-value="value"
-              label="治理对象类型"
-              hide-details
-            />
-          </v-col>
-          <v-col cols="12" md="5">
-            <v-text-field
-              v-model.trim="resourceUid"
-              label="对象标识"
-              placeholder="例如:DEVICE-DOMAIN 或设备 UID"
-              hide-details
-              @keyup.enter="loadMatrix"
-            />
+    <v-tabs v-model="tab" background-color="transparent" color="primary" class="mb-4">
+      <v-tab>责任继承与覆盖</v-tab>
+      <v-tab>委派与离岗转交</v-tab>
+      <v-tab>中央策略与联合评审</v-tab>
+      <v-tab>责任运营视图</v-tab>
+    </v-tabs>
+
+    <v-tabs-items v-model="tab" class="transparent">
+      <v-tab-item>
+        <v-card outlined class="mb-4">
+          <v-card-text>
+            <v-row align="center">
+              <v-col cols="12" md="4">
+                <v-select v-model="resourceType" :items="resourceTypeOptions" item-text="text" item-value="value" label="治理对象类型" hide-details />
+              </v-col>
+              <v-col cols="12" md="5">
+                <v-text-field v-model.trim="resourceUid" label="对象标识" placeholder="输入对象 UID 或业务编码" hide-details @keyup.enter="loadResponsibility" />
+              </v-col>
+              <v-col cols="12" md="3" class="text-right">
+                <v-btn color="primary" outlined :loading="loading" :disabled="!resourceUid" @click="loadResponsibility">
+                  <v-icon left>mdi-database-search-outline</v-icon>加载责任
+                </v-btn>
+              </v-col>
+            </v-row>
+          </v-card-text>
+        </v-card>
+
+        <v-row v-if="loaded">
+          <v-col cols="12" lg="8">
+            <v-card outlined>
+              <v-card-title>
+                责任矩阵
+                <v-chip small outlined color="primary" class="ml-3">修订 {{ revision }}</v-chip>
+                <v-spacer />
+                <v-btn text color="primary" @click="addAssignment"><v-icon left>mdi-account-plus-outline</v-icon>添加责任人</v-btn>
+              </v-card-title>
+              <v-divider />
+              <v-card-text>
+                <v-alert v-if="deviceScope" dense text type="info">
+                  设备治理对象必须且只能设置一名“设备资产管理员 + Accountable”。
+                </v-alert>
+                <v-row v-for="(assignment, index) in assignments" :key="`assignment-${index}`" align="center" class="assignment-row">
+                  <v-col cols="12" md="4"><v-select v-model="assignment.user_id" :items="activeUsers" :item-text="userLabel" item-value="id" label="用户" dense outlined hide-details /></v-col>
+                  <v-col cols="12" md="3"><v-select v-model="assignment.responsibility_role" :items="responsibilityRoleOptions" item-text="text" item-value="value" label="责任类型" dense outlined hide-details /></v-col>
+                  <v-col cols="12" md="3"><v-select v-model="assignment.raci_role" :items="raciOptions" item-text="text" item-value="value" label="RACI" dense outlined hide-details /></v-col>
+                  <v-col cols="12" md="2" class="text-right"><v-btn icon color="grey" @click="assignments.splice(index, 1)"><v-icon>mdi-delete-outline</v-icon></v-btn></v-col>
+                </v-row>
+                <div v-if="!assignments.length" class="empty-state text-center py-8 text--secondary">尚未配置责任人</div>
+              </v-card-text>
+              <v-card-actions class="px-6 py-4">
+                <span class="caption text--secondary">保存会记录版本、操作者和前后快照</span>
+                <v-spacer />
+                <v-btn color="primary" :loading="saving" @click="saveMatrix">{{ assignments.length ? '保存显式覆盖' : '清除覆盖并恢复继承' }}</v-btn>
+              </v-card-actions>
+            </v-card>
           </v-col>
-          <v-col cols="12" md="3" class="d-flex justify-end">
-            <v-btn
-              color="primary"
-              outlined
-              :loading="loading"
-              :disabled="!resourceUid"
-              @click="loadMatrix"
-            >
-              <v-icon left>mdi-database-search-outline</v-icon>
-              加载责任矩阵
-            </v-btn>
+          <v-col cols="12" lg="4">
+            <v-card outlined class="mb-4">
+              <v-card-title class="subtitle-1">唯一最终负责人</v-card-title>
+              <v-card-text>
+                <v-chip :color="resolutionColor" dark small class="mb-3">{{ resolution.status }}</v-chip>
+                <div v-for="owner in resolution.final_owners || []" :key="owner.effective_user_id" class="owner-card pa-3 mb-2">
+                  <div class="font-weight-medium">{{ owner.display_name || owner.username || owner.effective_user_id }}</div>
+                  <div class="caption text--secondary">{{ owner.responsibility_role }} · {{ owner.inherited ? '继承' : '显式覆盖' }}</div>
+                </div>
+                <v-alert dense text type="warning" v-if="resolution.status !== 'resolved'">当前对象尚未计算出唯一最终负责人。</v-alert>
+              </v-card-text>
+            </v-card>
+            <v-card outlined>
+              <v-card-title class="subtitle-1">责任层级</v-card-title>
+              <v-card-text>
+                <div v-for="node in resolution.chain || []" :key="`${node.resource_type}-${node.resource_uid}`" class="caption mb-2">
+                  {{ node.depth }} · {{ node.resource_type }} / {{ node.resource_uid }}
+                </div>
+                <v-divider class="my-3" />
+                <v-select v-model="parent.type" :items="hierarchicalTypeOptions" item-text="text" item-value="value" label="上级对象类型" dense outlined />
+                <v-text-field v-model.trim="parent.uid" label="上级对象标识" dense outlined />
+                <v-btn block outlined color="primary" :disabled="!parent.type || !parent.uid" @click="saveParent">更新继承关系</v-btn>
+              </v-card-text>
+            </v-card>
           </v-col>
         </v-row>
-      </v-card-text>
-    </v-card>
-
-    <v-card v-if="loaded" outlined>
-      <v-card-title class="d-flex align-center">
-        <div>
-          <div class="subtitle-1 font-weight-medium">{{ selectedResourceLabel }}</div>
-          <div class="caption text--secondary">{{ resourceUid }}</div>
-        </div>
-        <v-spacer />
-        <v-btn text color="primary" @click="addAssignment">
-          <v-icon left>mdi-account-plus-outline</v-icon>
-          添加责任人
-        </v-btn>
-      </v-card-title>
-      <v-divider />
-      <v-card-text>
-        <v-alert
-          v-if="deviceScope"
-          dense
-          text
-          type="info"
-          class="mb-4"
-        >
-          设备治理对象必须且只能设置一名“设备资产管理员 + Accountable”,
-          其他人员可按 RACI 参与执行、咨询或知会。
-        </v-alert>
+      </v-tab-item>
 
-        <v-row
-          v-for="(assignment, index) in assignments"
-          :key="`assignment-${index}`"
-          align="center"
-          class="assignment-row"
-        >
-          <v-col cols="12" md="4">
-            <v-select
-              v-model="assignment.user_id"
-              :items="activeUsers"
-              :item-text="userLabel"
-              item-value="id"
-              label="用户"
-              dense
-              outlined
-              hide-details
-            />
+      <v-tab-item>
+        <v-row>
+          <v-col cols="12" lg="5">
+            <v-card outlined>
+              <v-card-title>新建委派或转交</v-card-title>
+              <v-card-text>
+                <v-select v-model="delegation.delegation_type" :items="delegationTypes" item-text="text" item-value="value" label="处理方式" outlined dense />
+                <v-select v-model="delegation.source_user_uid" :items="activeUsers" :item-text="userLabel" item-value="id" label="原责任人" outlined dense />
+                <v-select v-model="delegation.delegate_user_uid" :items="activeUsers" :item-text="userLabel" item-value="id" label="接替人" outlined dense />
+                <v-select v-model="delegation.scope_type" clearable :items="hierarchicalTypeOptions" item-text="text" item-value="value" label="限定对象类型(可选)" outlined dense />
+                <v-text-field v-model.trim="delegation.scope_uid" :disabled="!delegation.scope_type" label="限定对象标识" outlined dense />
+                <v-select v-model="delegation.responsibility_role" clearable :items="responsibilityRoleOptions" item-text="text" item-value="value" label="限定责任类型(可选)" outlined dense />
+                <v-text-field v-if="delegation.delegation_type === 'temporary'" v-model="delegation.ends_at" label="到期时间(ISO-8601)" outlined dense />
+                <v-textarea v-model.trim="delegation.reason" label="原因" rows="2" outlined dense />
+                <v-btn block color="primary" :loading="saving" @click="saveDelegation">{{ delegation.delegation_type === 'temporary' ? '创建临时委派' : '执行离岗转交' }}</v-btn>
+              </v-card-text>
+            </v-card>
           </v-col>
-          <v-col cols="12" md="3">
-            <v-select
-              v-model="assignment.responsibility_role"
-              :items="responsibilityRoleOptions"
-              item-text="text"
-              item-value="value"
-              label="责任类型"
-              dense
-              outlined
-              hide-details
-            />
+          <v-col cols="12" lg="7">
+            <v-card outlined>
+              <v-card-title>生效记录<v-spacer /><v-btn text color="primary" @click="expireDelegations">执行到期收回</v-btn><v-btn text color="primary" @click="loadDelegations">刷新</v-btn></v-card-title>
+              <v-data-table :headers="delegationHeaders" :items="delegations" :loading="loading" item-key="uid">
+                <template v-slot:[`item.scope`]="{ item }">{{ item.scope_type ? `${item.scope_type} / ${item.scope_uid}` : '全局' }}</template>
+                <template v-slot:[`item.actions`]="{ item }"><v-btn v-if="item.status === 'active'" text small color="error" @click="revokeDelegation(item)">收回</v-btn></template>
+              </v-data-table>
+            </v-card>
           </v-col>
-          <v-col cols="12" md="3">
-            <v-select
-              v-model="assignment.raci_role"
-              :items="raciOptions"
-              item-text="text"
-              item-value="value"
-              label="RACI"
-              dense
-              outlined
-              hide-details
-            />
+        </v-row>
+      </v-tab-item>
+
+      <v-tab-item>
+        <v-row>
+          <v-col cols="12" lg="5">
+            <v-card outlined>
+              <v-card-title>{{ editingPolicy ? `修订策略 ${editingPolicy.code}` : '创建责任策略' }}</v-card-title>
+              <v-card-text>
+                <v-text-field v-model.trim="policy.code" :disabled="!!editingPolicy" label="策略编码" outlined dense />
+                <v-text-field v-model.trim="policy.name" :disabled="!!editingPolicy" label="策略名称" outlined dense />
+                <v-select v-model="policy.policy_type" :disabled="!!editingPolicy" :items="policyTypes" item-text="text" item-value="value" label="策略类型" outlined dense />
+                <v-select v-model="policy.scope_type" :disabled="!!editingPolicy" :items="hierarchicalTypeOptions" item-text="text" item-value="value" label="下发范围类型" outlined dense />
+                <v-text-field v-model.trim="policy.scope_uid" :disabled="!!editingPolicy" label="下发范围标识" outlined dense />
+                <v-textarea v-model="policy.definitionText" label="策略定义(JSON)" rows="8" outlined dense />
+                <v-btn block color="primary" :loading="saving" @click="savePolicy">{{ editingPolicy ? `创建版本 ${editingPolicy.current_version + 1}` : '创建版本 1 草稿' }}</v-btn>
+                <v-btn v-if="editingPolicy" block text class="mt-2" @click="resetPolicyForm">取消修订</v-btn>
+              </v-card-text>
+            </v-card>
           </v-col>
-          <v-col cols="12" md="2" class="text-right">
-            <v-btn icon color="grey" @click="removeAssignment(index)">
-              <v-icon>mdi-delete-outline</v-icon>
-            </v-btn>
+          <v-col cols="12" lg="7">
+            <v-card outlined>
+              <v-card-title>中央策略与联合评审<v-spacer /><v-btn text color="primary" @click="loadPolicies">刷新</v-btn></v-card-title>
+              <v-data-table :headers="policyHeaders" :items="policies" :loading="loading" item-key="uid">
+                <template v-slot:[`item.scope`]="{ item }">{{ item.scope_type }} / {{ item.scope_uid }}</template>
+                <template v-slot:[`item.actions`]="{ item }"><v-btn v-if="item.status !== 'retired'" text small @click="editPolicy(item)">新版本</v-btn><v-btn v-if="item.status === 'draft'" text small color="primary" @click="publishPolicy(item)">发布</v-btn></template>
+              </v-data-table>
+              <v-card-text class="caption text--secondary">联合评审策略只定义参与域和确定性判定规则;具体审批流由后续统一任务中心调用。</v-card-text>
+            </v-card>
           </v-col>
         </v-row>
+      </v-tab-item>
 
-        <div v-if="!assignments.length" class="empty-state text-center py-8">
-          <v-icon size="42" color="blue-grey lighten-2">mdi-account-question-outline</v-icon>
-          <div class="mt-2 text--secondary">尚未配置责任人</div>
-        </div>
-      </v-card-text>
-      <v-divider />
-      <v-card-actions class="px-6 py-4">
-        <div class="caption text--secondary">
-          保存时会记录操作者、修订号以及变更前后快照
-        </div>
-        <v-spacer />
-        <v-btn text @click="loadMatrix">放弃更改</v-btn>
-        <v-btn color="primary" :loading="saving" @click="saveMatrix">
-          保存责任矩阵
-        </v-btn>
-      </v-card-actions>
-    </v-card>
+      <v-tab-item>
+        <v-card outlined>
+          <v-card-title>责任运营视图</v-card-title>
+          <v-card-text>
+            <v-row align="center"><v-col cols="12" md="8"><v-select v-model="operationsOwner" :items="activeUsers" :item-text="userLabel" item-value="id" label="责任人" outlined dense hide-details /></v-col><v-col cols="12" md="4" class="text-right"><v-btn color="primary" outlined :disabled="!operationsOwner" @click="loadOperations">查询任务与指标</v-btn></v-col></v-row>
+            <v-row class="mt-4">
+              <v-col cols="6" md="3"><v-sheet outlined rounded class="metric-card pa-4"><div class="text-h5">{{ operations.summary.task_count || 0 }}</div><div class="caption">治理任务</div></v-sheet></v-col>
+              <v-col cols="6" md="3"><v-sheet outlined rounded class="metric-card pa-4"><div class="text-h5">{{ operations.summary.metric_count || 0 }}</div><div class="caption">责任指标</div></v-sheet></v-col>
+              <v-col cols="6" md="3"><v-sheet outlined rounded class="metric-card pa-4"><div class="text-h5 error--text">{{ operations.summary.overdue_count || 0 }}</div><div class="caption">逾期任务</div></v-sheet></v-col>
+              <v-col cols="6" md="3"><v-sheet outlined rounded class="metric-card pa-4"><div class="text-h5 warning--text">{{ operations.summary.recurrent_count || 0 }}</div><div class="caption">复发问题</div></v-sheet></v-col>
+            </v-row>
+            <v-data-table :headers="taskHeaders" :items="operations.tasks || []" class="mt-4" no-data-text="暂无关联任务" />
+          </v-card-text>
+        </v-card>
+      </v-tab-item>
+    </v-tabs-items>
   </v-container>
 </template>
 
 <script>
 import { listUsers } from '@/api/users'
 import {
-  getResponsibilityMatrix,
-  replaceResponsibilityMatrix
+  getResponsibilityMatrix, replaceResponsibilityMatrix, resolveResponsibility,
+  replaceResponsibilityParent, listResponsibilityDelegations,
+  createResponsibilityDelegation, revokeResponsibilityDelegation,
+  expireResponsibilityDelegations,
+  transferDepartingResponsibility, listResponsibilityPolicies,
+  createResponsibilityPolicy, reviseResponsibilityPolicy,
+  publishResponsibilityPolicy,
+  getResponsibilityOperations
 } from '@/api/responsibilities'
 
-const DEVICE_SCOPES = [
-  'device_asset',
-  'device_ontology',
-  'device_mapping',
-  'fault_classification'
-]
+const DEVICE_SCOPES = ['device_asset', 'device_ontology', 'device_mapping', 'device_quality', 'fault_classification']
 
 export default {
   name: 'SystemResponsibilityManage',
   data () {
     return {
+      tab: 0,
       loading: false,
       saving: false,
       loaded: false,
       revision: 0,
-      resourceType: 'device_asset',
+      resourceType: 'data_asset',
       resourceUid: '',
       users: [],
       assignments: [],
+      resolution: { status: 'unresolved', final_owners: [], chain: [] },
+      parent: { type: 'business_domain', uid: '' },
+      delegations: [],
+      policies: [],
+      editingPolicy: null,
+      operationsOwner: null,
+      operations: { tasks: [], metrics: [], summary: {} },
+      delegation: { delegation_type: 'temporary', source_user_uid: null, delegate_user_uid: null, scope_type: null, scope_uid: null, responsibility_role: null, ends_at: '', reason: '' },
+      policy: { code: '', name: '', policy_type: 'central_policy', scope_type: 'organization', scope_uid: '', definitionText: '{\n  "required_responsibility_roles": ["domain_owner"],\n  "require_unique_accountable": true,\n  "max_delegation_days": 90\n}' },
       resourceTypeOptions: [
-        { text: '设备资产', value: 'device_asset' },
-        { text: '设备本体', value: 'device_ontology' },
-        { text: '设备跨系统映射', value: 'device_mapping' },
-        { text: '故障分类', value: 'fault_classification' },
-        { text: '业务域', value: 'business_domain' },
-        { text: '质量问题', value: 'quality_issue' }
+        { text: '组织', value: 'organization' }, { text: '业务域', value: 'business_domain' },
+        { text: '数据资产', value: 'data_asset' }, { text: '语义术语', value: 'semantic_term' },
+        { text: '数据标准', value: 'data_standard' }, { text: '质量策略', value: 'quality_policy' },
+        { text: '数据产品', value: 'data_product' }, { text: '治理 Agent', value: 'agent' },
+        { text: '设备资产', value: 'device_asset' }, { text: '设备本体', value: 'device_ontology' },
+        { text: '设备映射', value: 'device_mapping' }, { text: '设备质量', value: 'device_quality' },
+        { text: '故障分类', value: 'fault_classification' }, { text: '质量问题', value: 'quality_issue' }
       ],
       responsibilityRoleOptions: [
-        { text: '业务域 Owner', value: 'domain_owner' },
-        { text: 'Data Steward', value: 'data_steward' },
-        { text: '数据架构师', value: 'data_architect' },
-        { text: '设备资产管理员', value: 'asset_manager' }
-      ],
-      raciOptions: [
-        { text: 'Responsible — 执行负责', value: 'responsible' },
-        { text: 'Accountable — 最终负责', value: 'accountable' },
-        { text: 'Consulted — 协商参与', value: 'consulted' },
-        { text: 'Informed — 结果知会', value: 'informed' }
-      ]
+        ['组织 Owner', 'organization_owner'], ['业务域 Owner', 'domain_owner'], ['Data Steward', 'data_steward'],
+        ['数据架构师', 'data_architect'], ['设备资产管理员', 'asset_manager'], ['术语管理员', 'term_steward'],
+        ['标准 Owner', 'standard_owner'], ['质量 Owner', 'quality_owner'], ['产品 Owner', 'product_owner'], ['Agent Owner', 'agent_owner']
+      ].map(([text, value]) => ({ text, value })),
+      raciOptions: [['Responsible — 执行负责', 'responsible'], ['Accountable — 最终负责', 'accountable'], ['Consulted — 协商参与', 'consulted'], ['Informed — 结果知会', 'informed']].map(([text, value]) => ({ text, value })),
+      delegationTypes: [{ text: '临时委派(到期自动收回)', value: 'temporary' }, { text: '离岗转交', value: 'departure_transfer' }],
+      policyTypes: [{ text: '中央责任策略', value: 'central_policy' }, { text: '跨域联合评审', value: 'joint_review' }],
+      delegationHeaders: [{ text: '原责任人', value: 'source_user_uid' }, { text: '接替人', value: 'delegate_user_uid' }, { text: '范围', value: 'scope' }, { text: '类型', value: 'delegation_type' }, { text: '状态', value: 'status' }, { text: '', value: 'actions', sortable: false }],
+      policyHeaders: [{ text: '策略', value: 'name' }, { text: '类型', value: 'policy_type' }, { text: '范围', value: 'scope' }, { text: '版本', value: 'current_version' }, { text: '状态', value: 'status' }, { text: '', value: 'actions', sortable: false }],
+      taskHeaders: [{ text: '类型', value: 'kind' }, { text: '标题', value: 'title' }, { text: '状态', value: 'status' }, { text: '逾期', value: 'overdue' }, { text: '复发次数', value: 'recurrence_count' }]
     }
   },
   computed: {
-    activeUsers () {
-      return this.users.filter(item => item.status === 'active')
-    },
-    deviceScope () {
-      return DEVICE_SCOPES.includes(this.resourceType)
-    },
-    selectedResourceLabel () {
-      return this.resourceTypeOptions.find(item => item.value === this.resourceType)?.text || this.resourceType
-    }
+    activeUsers () { return this.users.filter(item => item.status === 'active') },
+    deviceScope () { return DEVICE_SCOPES.includes(this.resourceType) },
+    hierarchicalTypeOptions () { return this.resourceTypeOptions.filter(item => ['organization', 'business_domain', 'data_asset', 'semantic_term', 'data_standard', 'quality_policy', 'data_product', 'agent'].includes(item.value)) },
+    resolutionColor () { return this.resolution.status === 'resolved' ? 'success' : (this.resolution.status === 'ambiguous' ? 'warning' : 'error') }
   },
   async created () {
-    try {
-      const response = await listUsers()
-      this.users = response.data || []
-    } catch (error) {
-      this.$snackbar.error(error)
-    }
+    try { const response = await listUsers(); this.users = response.data || [] } catch (error) { this.$snackbar.error(error) }
+    await Promise.all([this.loadDelegations(), this.loadPolicies()])
   },
   methods: {
-    userLabel (item) {
-      return `${item.display_name || item.username}(${item.username})`
-    },
-    addAssignment () {
-      this.assignments.push({
-        user_id: null,
-        responsibility_role: this.deviceScope ? 'asset_manager' : 'data_steward',
-        raci_role: this.assignments.length ? 'responsible' : 'accountable'
-      })
-    },
-    removeAssignment (index) {
-      this.assignments.splice(index, 1)
-    },
-    async loadMatrix () {
+    userLabel (item) { return `${item.display_name || item.username}(${item.username})` },
+    addAssignment () { this.assignments.push({ user_id: null, responsibility_role: this.deviceScope ? 'asset_manager' : 'data_steward', raci_role: this.assignments.length ? 'responsible' : 'accountable' }) },
+    async loadResponsibility () {
       if (!this.resourceUid) return
       this.loading = true
       try {
-        const response = await getResponsibilityMatrix(this.resourceType, this.resourceUid)
-        this.revision = response.data.revision
-        this.assignments = response.data.assignments.map(item => ({
+        const [matrix, resolved] = await Promise.all([getResponsibilityMatrix(this.resourceType, this.resourceUid), resolveResponsibility(this.resourceType, this.resourceUid)])
+        this.revision = matrix.data.revision
+        this.assignments = matrix.data.assignments.map(item => ({
           user_id: item.user_id,
           responsibility_role: item.responsibility_role,
           raci_role: item.raci_role
         }))
+        this.resolution = resolved.data
+        if (resolved.data.chain?.[1]) {
+          this.parent = {
+            type: resolved.data.chain[1].resource_type,
+            uid: resolved.data.chain[1].resource_uid
+          }
+        }
         this.loaded = true
-      } catch (error) {
-        this.$snackbar.error(error)
-      } finally {
-        this.loading = false
-      }
+      } catch (error) { this.$snackbar.error(error) } finally { this.loading = false }
     },
-    validate () {
-      if (!this.assignments.length || this.assignments.some(item => !item.user_id)) {
-        return '请完整配置至少一名责任人'
-      }
-      if (this.deviceScope) {
-        const accountable = this.assignments.filter(item => (
-          item.responsibility_role === 'asset_manager' &&
-          item.raci_role === 'accountable'
-        ))
-        if (accountable.length !== 1) {
-          return '设备治理对象必须且只能设置一名最终负责的设备资产管理员'
-        }
-      }
+    validateMatrix () {
+      if (this.assignments.some(item => !item.user_id)) return '请完整配置责任人'
+      if (this.deviceScope && !this.assignments.length) return '设备治理对象不能清空责任矩阵'
+      if (this.deviceScope && this.assignments.filter(item => item.responsibility_role === 'asset_manager' && item.raci_role === 'accountable').length !== 1) return '设备治理对象必须且只能设置一名最终负责的设备资产管理员'
       return null
     },
     async saveMatrix () {
-      const error = this.validate()
-      if (error) {
-        this.$snackbar.error(error)
-        return
-      }
+      const message = this.validateMatrix(); if (message) return this.$snackbar.error(message)
+      this.saving = true
+      try { await replaceResponsibilityMatrix(this.resourceType, this.resourceUid, this.assignments, this.revision); this.$snackbar.success('责任矩阵已保存'); await this.loadResponsibility() } catch (error) { this.$snackbar.error(error?.code === 409 ? '责任矩阵已被其他管理员修改,请重新加载' : error) } finally { this.saving = false }
+    },
+    async saveParent () {
+      try { await replaceResponsibilityParent(this.resourceType, this.resourceUid, { parent_type: this.parent.type, parent_uid: this.parent.uid }, this.resolution.chain?.[0]?.hierarchy_revision || 0); this.$snackbar.success('责任继承关系已更新'); await this.loadResponsibility() } catch (error) { this.$snackbar.error(error) }
+    },
+    async loadDelegations () { try { const response = await listResponsibilityDelegations(); this.delegations = response.data || [] } catch (error) { this.$snackbar.error(error) } },
+    async saveDelegation () {
       this.saving = true
       try {
-        const response = await replaceResponsibilityMatrix(
-          this.resourceType,
-          this.resourceUid,
-          this.assignments,
-          this.revision
-        )
-        this.revision = response.data.revision
-        this.$snackbar.success('责任矩阵已保存')
-        await this.loadMatrix()
-      } catch (error) {
-        if (error?.code === 409) {
-          this.$snackbar.error('责任矩阵已被其他管理员修改,请重新加载')
+        const payload = { ...this.delegation, scope_uid: this.delegation.scope_type ? this.delegation.scope_uid : null }
+        if (payload.delegation_type === 'temporary') {
+          payload.starts_at = new Date().toISOString()
         } else {
-          this.$snackbar.error(error)
+          delete payload.ends_at
+          delete payload.delegation_type
         }
-      } finally {
-        this.saving = false
-      }
-    }
+        const operation = this.delegation.delegation_type === 'temporary' ? createResponsibilityDelegation(payload) : transferDepartingResponsibility(payload)
+        await operation; this.$snackbar.success('责任委派已保存'); await this.loadDelegations()
+      } catch (error) { this.$snackbar.error(error) } finally { this.saving = false }
+    },
+    async revokeDelegation (item) { try { await revokeResponsibilityDelegation(item.uid, item.current_version); await this.loadDelegations() } catch (error) { this.$snackbar.error(error) } },
+    async expireDelegations () { try { await expireResponsibilityDelegations(); this.$snackbar.success('到期委派状态已收回'); await this.loadDelegations() } catch (error) { this.$snackbar.error(error) } },
+    async loadPolicies () { try { const response = await listResponsibilityPolicies(); this.policies = response.data || [] } catch (error) { this.$snackbar.error(error) } },
+    editPolicy (item) { this.editingPolicy = item; this.policy = { code: item.code, name: item.name, policy_type: item.policy_type, scope_type: item.scope_type, scope_uid: item.scope_uid, definitionText: JSON.stringify(item.active_definition || {}, null, 2) } },
+    resetPolicyForm () { this.editingPolicy = null; this.policy = { code: '', name: '', policy_type: 'central_policy', scope_type: 'organization', scope_uid: '', definitionText: '{\n  "required_responsibility_roles": ["domain_owner"],\n  "require_unique_accountable": true,\n  "max_delegation_days": 90\n}' } },
+    async savePolicy () { this.saving = true; try { const { definitionText, ...policy } = this.policy; const definition = JSON.parse(definitionText); if (this.editingPolicy) { await reviseResponsibilityPolicy(this.editingPolicy.uid, definition, this.editingPolicy.current_version) } else { await createResponsibilityPolicy({ ...policy, definition }) }; this.$snackbar.success('责任策略草稿已保存'); this.resetPolicyForm(); await this.loadPolicies() } catch (error) { this.$snackbar.error(error) } finally { this.saving = false } },
+    async publishPolicy (item) { try { await publishResponsibilityPolicy(item.uid, item.current_version); this.$snackbar.success('责任策略已发布'); await this.loadPolicies() } catch (error) { this.$snackbar.error(error) } },
+    async loadOperations () { try { const response = await getResponsibilityOperations(this.operationsOwner); this.operations = response.data } catch (error) { this.$snackbar.error(error) } }
   }
 }
 </script>
 
 <style scoped>
-.responsibility-page {
-  max-width: 1280px;
-}
-
-.assignment-row + .assignment-row {
-  border-top: 1px solid rgba(15, 23, 42, 0.08);
-  padding-top: 16px;
-}
-
-.empty-state {
-  border: 1px dashed rgba(55, 71, 79, 0.28);
-  border-radius: 8px;
-  background: rgba(236, 239, 241, 0.35);
-}
+.responsibility-page { max-width: 1440px; }
+.transparent { background: transparent !important; }
+.assignment-row + .assignment-row { border-top: 1px solid rgba(15, 23, 42, 0.08); padding-top: 16px; }
+.empty-state { border: 1px dashed rgba(55, 71, 79, 0.28); border-radius: 8px; background: rgba(236, 239, 241, 0.35); }
+.owner-card, .metric-card { border: 1px solid rgba(15, 23, 42, 0.1); border-radius: 8px; background: rgba(248, 250, 252, 0.7); }
 </style>

+ 198 - 0
migrations/versions/20260801_420_unified_responsibilities.py

@@ -0,0 +1,198 @@
+"""Add hierarchical, delegated and policy-driven governance responsibility."""
+
+from alembic import op
+
+
+revision = "20260801_420"
+down_revision = "20260731_410"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        ALTER TABLE public.governance_responsibility_scopes
+            DROP CONSTRAINT IF EXISTS
+                governance_responsibility_scopes_resource_type_check;
+        ALTER TABLE public.governance_responsibility_scopes
+            ADD CONSTRAINT
+                governance_responsibility_scopes_resource_type_check
+            CHECK (
+                resource_type IN (
+                    'business_domain','device_asset','device_ontology',
+                    'device_mapping','device_quality','fault_classification',
+                    'quality_issue','organization','data_asset','semantic_term',
+                    'data_standard','quality_policy','data_product','agent'
+                )
+            ) NOT VALID;
+        ALTER TABLE public.governance_responsibility_scopes
+            VALIDATE CONSTRAINT
+                governance_responsibility_scopes_resource_type_check;
+
+        ALTER TABLE public.governance_responsibility_assignments
+            DROP CONSTRAINT IF EXISTS
+                governance_responsibility_assignments_responsibility_role_check;
+        ALTER TABLE public.governance_responsibility_assignments
+            ADD CONSTRAINT
+                governance_responsibility_assignments_responsibility_role_check
+            CHECK (
+                responsibility_role IN (
+                    'organization_owner','domain_owner','data_steward',
+                    'data_architect','asset_manager','term_steward',
+                    'standard_owner','quality_owner','product_owner','agent_owner'
+                )
+            ) NOT VALID;
+        ALTER TABLE public.governance_responsibility_assignments
+            VALIDATE CONSTRAINT
+                governance_responsibility_assignments_responsibility_role_check;
+
+        CREATE TABLE public.governance_responsibility_hierarchy (
+            uid UUID PRIMARY KEY,
+            resource_type VARCHAR(40) NOT NULL,
+            resource_uid VARCHAR(120) NOT NULL,
+            parent_type VARCHAR(40) NOT NULL,
+            parent_uid VARCHAR(120) NOT NULL,
+            revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (resource_type, resource_uid),
+            CHECK (
+                resource_type IN (
+                    'organization','business_domain','data_asset','semantic_term',
+                    'data_standard','quality_policy','data_product','agent'
+                )
+            ),
+            CHECK (
+                parent_type IN (
+                    'organization','business_domain','data_asset','semantic_term',
+                    'data_standard','quality_policy','data_product','agent'
+                )
+            ),
+            CHECK (
+                resource_type <> parent_type OR resource_uid <> parent_uid
+            )
+        );
+        CREATE INDEX idx_governance_responsibility_hierarchy_parent
+            ON public.governance_responsibility_hierarchy(
+                parent_type, parent_uid
+            );
+
+        CREATE TABLE public.governance_responsibility_delegations (
+            uid UUID PRIMARY KEY,
+            source_user_uid UUID NOT NULL REFERENCES public.users(id),
+            delegate_user_uid UUID NOT NULL REFERENCES public.users(id),
+            scope_type VARCHAR(40),
+            scope_uid VARCHAR(120),
+            responsibility_role VARCHAR(40),
+            delegation_type VARCHAR(30) NOT NULL CHECK (
+                delegation_type IN ('temporary','departure_transfer')
+            ),
+            starts_at TIMESTAMPTZ NOT NULL,
+            ends_at TIMESTAMPTZ,
+            reason VARCHAR(500) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('active','revoked','expired')
+            ),
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (
+                current_version > 0
+            ),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (source_user_uid <> delegate_user_uid),
+            CHECK ((scope_type IS NULL) = (scope_uid IS NULL)),
+            CHECK (
+                scope_type IS NULL OR scope_type IN (
+                    'organization','business_domain','data_asset','semantic_term',
+                    'data_standard','quality_policy','data_product','agent'
+                )
+            ),
+            CHECK (
+                responsibility_role IS NULL OR responsibility_role IN (
+                    'organization_owner','domain_owner','data_steward',
+                    'data_architect','asset_manager','term_steward',
+                    'standard_owner','quality_owner','product_owner','agent_owner'
+                )
+            ),
+            CHECK (
+                (delegation_type = 'temporary' AND ends_at > starts_at)
+                OR
+                (delegation_type = 'departure_transfer' AND ends_at IS NULL)
+            )
+        );
+        CREATE UNIQUE INDEX uq_governance_responsibility_active_delegation
+            ON public.governance_responsibility_delegations(
+                source_user_uid,
+                COALESCE(scope_type, ''), COALESCE(scope_uid, ''),
+                COALESCE(responsibility_role, '')
+            ) WHERE status = 'active';
+        CREATE INDEX idx_governance_responsibility_delegate
+            ON public.governance_responsibility_delegations(
+                delegate_user_uid, status, ends_at
+            );
+
+        CREATE TABLE public.governance_responsibility_policies (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            policy_type VARCHAR(30) NOT NULL CHECK (
+                policy_type IN ('central_policy','joint_review')
+            ),
+            scope_type VARCHAR(40) NOT NULL CHECK (
+                scope_type IN (
+                    'organization','business_domain','data_asset','semantic_term',
+                    'data_standard','quality_policy','data_product','agent'
+                )
+            ),
+            scope_uid VARCHAR(120) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','retired')
+            ),
+            current_version INTEGER NOT NULL DEFAULT 1 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_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.governance_responsibility_policy_versions (
+            uid UUID PRIMARY KEY,
+            policy_uid UUID NOT NULL
+                REFERENCES public.governance_responsibility_policies(uid),
+            version INTEGER NOT NULL CHECK (version > 0),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','superseded')
+            ),
+            definition 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 (policy_uid, version),
+            CHECK (jsonb_typeof(definition) = 'object')
+        );
+        ALTER TABLE public.governance_responsibility_policies
+            ADD CONSTRAINT governance_responsibility_active_version_fk
+            FOREIGN KEY (active_version_uid)
+            REFERENCES public.governance_responsibility_policy_versions(uid);
+        CREATE UNIQUE INDEX uq_governance_responsibility_published_version
+            ON public.governance_responsibility_policy_versions(policy_uid)
+            WHERE status = 'published';
+        CREATE INDEX idx_governance_responsibility_policy_scope
+            ON public.governance_responsibility_policies(
+                scope_type, scope_uid, status
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "responsibility delegation and policy evidence is retained; "
+        "downgrade requires an approved archival migration"
+    )

+ 7 - 0
tests/core/governance/test_responsibilities.py

@@ -87,6 +87,13 @@ def test_responsibility_matrix_rejects_duplicates_and_unknown_values():
             ],
         )
 
+    assert validate_matrix("data_asset", []) == ()
+    with pytest.raises(
+        ResponsibilityValidationError,
+        match="one accountable asset manager",
+    ):
+        validate_matrix("device_asset", [])
+
 
 def test_replace_is_revision_guarded_and_rejects_inactive_users():
     from app.core.governance.responsibilities import (

+ 500 - 0
tests/core/governance/test_unified_responsibilities.py

@@ -0,0 +1,500 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+from app.core.governance.unified_responsibilities import (
+    UnifiedResponsibilityService,
+)
+
+USER_A = "01900000-0000-7000-8000-000000006001"
+USER_B = "01900000-0000-7000-8000-000000006002"
+USER_C = "01900000-0000-7000-8000-000000006003"
+ACTOR = "01900000-0000-7000-8000-000000006004"
+NOW = datetime(2026, 8, 1, 9, 0, tzinfo=UTC)
+
+OBJECT_TYPES = (
+    "data_asset",
+    "semantic_term",
+    "data_standard",
+    "quality_policy",
+    "data_product",
+    "agent",
+)
+
+
+class MemoryUnifiedResponsibilityRepository:
+    def __init__(self):
+        self.matrices = {}
+        self.parents = {}
+        self.hierarchy_revisions = {}
+        self.delegations = {}
+        self.policies = {}
+        self.policy_versions = {}
+        self.audit = []
+        self.users = {USER_A: True, USER_B: True, USER_C: True, ACTOR: True}
+        self.work = {
+            USER_A: {
+                "tasks": [
+                    {
+                        "kind": "quality_issue",
+                        "uid": "issue-1",
+                        "overdue": True,
+                        "recurrence_count": 3,
+                    }
+                ],
+                "metrics": [
+                    {
+                        "kind": "slo",
+                        "uid": "slo-1",
+                        "target": 0.99,
+                    }
+                ],
+            }
+        }
+
+    def get(self, resource_type, resource_uid):
+        assignments = self.matrices.get((resource_type, resource_uid), [])
+        return {
+            "resource_type": resource_type,
+            "resource_uid": resource_uid,
+            "revision": 1 if assignments else 0,
+            "assignments": deepcopy(assignments),
+        }
+
+    def parent(self, resource_type, resource_uid):
+        value = self.parents.get((resource_type, resource_uid))
+        return deepcopy(value) if value else None
+
+    def set_parent(self, record, expected_revision):
+        key = (record["resource_type"], record["resource_uid"])
+        current = self.hierarchy_revisions.get(key, 0)
+        if current != expected_revision:
+            raise RuntimeError("hierarchy revision conflict")
+        updated = {**record, "revision": current + 1}
+        self.parents[key] = deepcopy(updated)
+        self.hierarchy_revisions[key] = current + 1
+        self.audit.append(("hierarchy_replaced", deepcopy(updated)))
+        return deepcopy(updated)
+
+    def active_delegation(
+        self,
+        source_user_uid,
+        responsibility_role,
+        chain,
+        at,
+    ):
+        candidates = []
+        for item in self.delegations.values():
+            if item["source_user_uid"] != source_user_uid:
+                continue
+            if item["status"] != "active":
+                continue
+            if item.get("responsibility_role") not in {
+                None,
+                responsibility_role,
+            }:
+                continue
+            starts_at = datetime.fromisoformat(item["starts_at"])
+            ends_at = (
+                datetime.fromisoformat(item["ends_at"])
+                if item.get("ends_at")
+                else None
+            )
+            if starts_at > at or (ends_at is not None and ends_at <= at):
+                continue
+            scope = (item.get("scope_type"), item.get("scope_uid"))
+            depth = next(
+                (
+                    node["depth"]
+                    for node in chain
+                    if (node["resource_type"], node["resource_uid"])
+                    == scope
+                ),
+                None,
+            )
+            if scope != (None, None) and depth is None:
+                continue
+            candidates.append((depth if depth is not None else 999, item))
+        if not candidates:
+            return None
+        return deepcopy(sorted(candidates, key=lambda value: value[0])[0][1])
+
+    def users_available(self, user_uids):
+        return {uid for uid in user_uids if self.users.get(uid)}
+
+    def create_delegation(self, record):
+        self.delegations[record["uid"]] = deepcopy(record)
+        self.audit.append(("delegation_created", deepcopy(record)))
+        return deepcopy(record)
+
+    def get_delegation(self, uid):
+        value = self.delegations.get(uid)
+        return deepcopy(value) if value else None
+
+    def update_delegation(self, record, expected_version):
+        current = self.delegations[record["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("delegation version conflict")
+        updated = {**record, "current_version": expected_version + 1}
+        self.delegations[record["uid"]] = deepcopy(updated)
+        self.audit.append((f"delegation_{updated['status']}", deepcopy(updated)))
+        return deepcopy(updated)
+
+    def expire_delegations(self, at, actor_uid):
+        expired = []
+        for uid, item in tuple(self.delegations.items()):
+            if (
+                item["status"] == "active"
+                and item.get("ends_at")
+                and datetime.fromisoformat(item["ends_at"]) <= at
+            ):
+                updated = {
+                    **item,
+                    "status": "expired",
+                    "current_version": item["current_version"] + 1,
+                    "updated_by": actor_uid,
+                }
+                self.delegations[uid] = updated
+                expired.append(deepcopy(updated))
+        return expired
+
+    def create_policy(self, policy, version):
+        self.policies[policy["uid"]] = deepcopy(policy)
+        self.policy_versions[version["uid"]] = deepcopy(version)
+        return deepcopy(policy)
+
+    def get_policy(self, uid):
+        value = self.policies.get(uid)
+        return deepcopy(value) if value else None
+
+    def policy_version(self, policy_uid, version):
+        return next(
+            (
+                deepcopy(item)
+                for item in self.policy_versions.values()
+                if item["policy_uid"] == policy_uid
+                and item["version"] == version
+            ),
+            None,
+        )
+
+    def revise_policy(self, policy, version, expected_version):
+        current = self.policies[policy["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("policy version conflict")
+        self.policies[policy["uid"]] = deepcopy(policy)
+        self.policy_versions[version["uid"]] = deepcopy(version)
+        return deepcopy(policy)
+
+    def publish_policy(self, policy, version, expected_version):
+        current = self.policies[policy["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("policy version conflict")
+        self.policies[policy["uid"]] = deepcopy(policy)
+        self.policy_versions[version["uid"]] = deepcopy(version)
+        return deepcopy(policy)
+
+    def policies_for_chain(self, chain):
+        scopes = {
+            (item["resource_type"], item["resource_uid"])
+            for item in chain
+        }
+        result = []
+        for policy in self.policies.values():
+            if policy["status"] != "published":
+                continue
+            if (policy["scope_type"], policy["scope_uid"]) not in scopes:
+                continue
+            version = self.policy_version(
+                policy["uid"], policy["current_version"]
+            )
+            result.append({**deepcopy(policy), "definition": version["definition"]})
+        return result
+
+    def responsibility_operations(self, owner_uid):
+        return deepcopy(
+            self.work.get(owner_uid, {"tasks": [], "metrics": []})
+        )
+
+
+@pytest.fixture()
+def service():
+    repository = MemoryUnifiedResponsibilityRepository()
+    uids = (
+        f"01900000-0000-7000-8000-{number:012d}"
+        for number in range(6100, 6500)
+    )
+    instance = UnifiedResponsibilityService(
+        repository,
+        uid_factory=lambda: next(uids),
+        now_factory=lambda: NOW,
+    )
+    return instance, repository
+
+
+def accountable(user_uid, role="domain_owner"):
+    return {
+        "user_id": user_uid,
+        "username": user_uid[-4:],
+        "display_name": user_uid[-4:],
+        "responsibility_role": role,
+        "raci_role": "accountable",
+    }
+
+
+def test_six_governance_object_types_resolve_one_final_owner_with_override(service):
+    responsibilities, repository = service
+    repository.matrices[("organization", "org-1")] = [
+        accountable(USER_A, "organization_owner")
+    ]
+    repository.parents[("business_domain", "domain-1")] = {
+        "resource_type": "business_domain",
+        "resource_uid": "domain-1",
+        "parent_type": "organization",
+        "parent_uid": "org-1",
+        "revision": 1,
+    }
+    for object_type in OBJECT_TYPES:
+        repository.parents[(object_type, f"{object_type}-1")] = {
+            "resource_type": object_type,
+            "resource_uid": f"{object_type}-1",
+            "parent_type": "business_domain",
+            "parent_uid": "domain-1",
+            "revision": 1,
+        }
+    repository.matrices[("data_product", "data_product-1")] = [
+        accountable(USER_B, "product_owner")
+    ]
+
+    resolved = {
+        object_type: responsibilities.resolve(
+            object_type, f"{object_type}-1"
+        )
+        for object_type in OBJECT_TYPES
+    }
+
+    assert all(item["status"] == "resolved" for item in resolved.values())
+    assert all(len(item["final_owners"]) == 1 for item in resolved.values())
+    assert resolved["data_asset"]["final_owners"][0]["effective_user_id"] == USER_A
+    assert resolved["data_asset"]["final_owners"][0]["inherited"] is True
+    assert resolved["data_product"]["final_owners"][0]["effective_user_id"] == USER_B
+    assert resolved["data_product"]["final_owners"][0]["inherited"] is False
+    assert all(item["grants_data_access"] is False for item in resolved.values())
+
+
+def test_hierarchy_replacement_is_revision_guarded_and_rejects_cycles(service):
+    responsibilities, repository = service
+    repository.parents[("business_domain", "domain-1")] = {
+        "resource_type": "business_domain",
+        "resource_uid": "domain-1",
+        "parent_type": "organization",
+        "parent_uid": "org-1",
+        "revision": 1,
+    }
+
+    saved = responsibilities.set_parent(
+        {
+            "resource_type": "data_asset",
+            "resource_uid": "asset-1",
+            "parent_type": "business_domain",
+            "parent_uid": "domain-1",
+        },
+        expected_revision=0,
+        actor_uid=ACTOR,
+    )
+    assert saved["revision"] == 1
+    assert repository.audit[-1][0] == "hierarchy_replaced"
+
+    with pytest.raises(RuntimeError, match="revision conflict"):
+        responsibilities.set_parent(
+            {
+                "resource_type": "data_asset",
+                "resource_uid": "asset-1",
+                "parent_type": "organization",
+                "parent_uid": "org-1",
+            },
+            expected_revision=0,
+            actor_uid=ACTOR,
+        )
+
+    with pytest.raises(ValueError, match="cycle"):
+        responsibilities.set_parent(
+            {
+                "resource_type": "organization",
+                "resource_uid": "org-1",
+                "parent_type": "data_asset",
+                "parent_uid": "asset-1",
+            },
+            expected_revision=0,
+            actor_uid=ACTOR,
+        )
+
+
+def test_temporary_delegation_expires_and_departure_transfer_remains_effective(service):
+    responsibilities, repository = service
+    repository.matrices[("data_asset", "asset-1")] = [
+        accountable(USER_A, "asset_manager")
+    ]
+
+    temporary = responsibilities.create_delegation(
+        {
+            "source_user_uid": USER_A,
+            "delegate_user_uid": USER_B,
+            "scope_type": "data_asset",
+            "scope_uid": "asset-1",
+            "responsibility_role": "asset_manager",
+            "delegation_type": "temporary",
+            "starts_at": NOW.isoformat(),
+            "ends_at": (NOW + timedelta(hours=2)).isoformat(),
+            "reason": "短期休假代理",
+        },
+        actor_uid=ACTOR,
+    )
+    delegated = responsibilities.resolve("data_asset", "asset-1", at=NOW)
+    assert delegated["final_owners"][0]["effective_user_id"] == USER_B
+    assert delegated["final_owners"][0]["delegation_uid"] == temporary["uid"]
+
+    expired = responsibilities.expire_delegations(
+        at=NOW + timedelta(hours=3), actor_uid=ACTOR
+    )
+    assert [item["status"] for item in expired] == ["expired"]
+    restored = responsibilities.resolve(
+        "data_asset", "asset-1", at=NOW + timedelta(hours=3)
+    )
+    assert restored["final_owners"][0]["effective_user_id"] == USER_A
+
+    transfer = responsibilities.transfer_departing_user(
+        {
+            "source_user_uid": USER_A,
+            "delegate_user_uid": USER_C,
+            "scope_type": "data_asset",
+            "scope_uid": "asset-1",
+            "reason": "离岗责任转交",
+        },
+        actor_uid=ACTOR,
+    )
+    assert transfer["delegation_type"] == "departure_transfer"
+    transferred = responsibilities.resolve(
+        "data_asset", "asset-1", at=NOW + timedelta(days=30)
+    )
+    assert transferred["final_owners"][0]["effective_user_id"] == USER_C
+
+
+def test_central_policy_and_cross_domain_joint_review_are_deterministic(service):
+    responsibilities, repository = service
+    repository.matrices[("data_product", "product-1")] = [
+        accountable(USER_A, "product_owner"),
+        {
+            "user_id": USER_B,
+            "username": "user-b",
+            "display_name": "User B",
+            "responsibility_role": "data_steward",
+            "raci_role": "responsible",
+        },
+    ]
+    repository.matrices[("business_domain", "domain-a")] = [
+        accountable(USER_A, "domain_owner")
+    ]
+    repository.matrices[("business_domain", "domain-b")] = [
+        accountable(USER_B, "domain_owner")
+    ]
+    central = responsibilities.create_policy(
+        {
+            "code": "CENTRAL_PRODUCT_RESPONSIBILITY",
+            "name": "中央产品责任策略",
+            "policy_type": "central_policy",
+            "scope_type": "data_product",
+            "scope_uid": "product-1",
+            "definition": {
+                "required_responsibility_roles": [
+                    "product_owner",
+                    "data_steward",
+                ],
+                "require_unique_accountable": True,
+                "max_delegation_days": 30,
+            },
+        },
+        actor_uid=ACTOR,
+    )
+    joint = responsibilities.create_policy(
+        {
+            "code": "CROSS_DOMAIN_PRODUCT_REVIEW",
+            "name": "跨域产品联合评审",
+            "policy_type": "joint_review",
+            "scope_type": "data_product",
+            "scope_uid": "product-1",
+            "definition": {
+                "business_domain_uids": ["domain-a", "domain-b"],
+                "min_approvals": 2,
+                "require_all_domains": True,
+            },
+        },
+        actor_uid=ACTOR,
+    )
+    responsibilities.publish_policy(
+        central["uid"], expected_version=1, actor_uid=ACTOR
+    )
+    responsibilities.publish_policy(
+        joint["uid"], expected_version=1, actor_uid=ACTOR
+    )
+
+    resolved = responsibilities.resolve("data_product", "product-1")
+    assert resolved["policy_compliance"]["status"] == "compliant"
+    assert resolved["joint_review"]["required_domains"] == [
+        "domain-a",
+        "domain-b",
+    ]
+
+    pending = responsibilities.evaluate_joint_review(
+        "data_product",
+        "product-1",
+        [{"user_uid": USER_A, "domain_uid": "domain-a", "decision": "approve"}],
+    )
+    approved = responsibilities.evaluate_joint_review(
+        "data_product",
+        "product-1",
+        [
+            {"user_uid": USER_A, "domain_uid": "domain-a", "decision": "approve"},
+            {"user_uid": USER_B, "domain_uid": "domain-b", "decision": "approve"},
+        ],
+    )
+
+    assert pending["status"] == "pending"
+    assert pending["missing_domains"] == ["domain-b"]
+    assert approved["status"] == "approved"
+    assert approved["approval_count"] == 2
+
+
+def test_responsibility_operations_link_tasks_metrics_overdue_and_recurrence(service):
+    responsibilities, _repository = service
+
+    result = responsibilities.operations(USER_A)
+
+    assert result["owner_uid"] == USER_A
+    assert result["summary"] == {
+        "task_count": 1,
+        "metric_count": 1,
+        "overdue_count": 1,
+        "recurrent_count": 1,
+    }
+    assert result["tasks"][0]["recurrence_count"] == 3
+    assert result["grants_data_access"] is False
+
+
+def test_responsibility_resolution_never_mutates_platform_permissions(service):
+    from app.core.system.permissions import permissions_for_roles
+
+    responsibilities, repository = service
+    repository.matrices[("data_asset", "asset-1")] = [
+        accountable(USER_A, "asset_manager")
+    ]
+    before = permissions_for_roles(["viewer"])
+
+    resolved = responsibilities.resolve("data_asset", "asset-1")
+
+    assert resolved["grants_data_access"] is False
+    assert permissions_for_roles(["viewer"]) == before
+    assert "governance:responsibilities:manage" not in before

+ 255 - 0
tests/integration/test_unified_responsibility_postgres.py

@@ -0,0 +1,255 @@
+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
+
+pytestmark = pytest.mark.integration
+
+OBJECT_TYPES = (
+    "data_asset",
+    "semantic_term",
+    "data_standard",
+    "quality_policy",
+    "data_product",
+    "agent",
+)
+
+
+def test_unified_responsibility_chain_delegation_policy_and_audit():
+    database_url = os.environ.get("TEST_DATABASE_URL")
+    if not database_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+    from app.core.governance.unified_responsibilities import (
+        UnifiedResponsibilityService,
+    )
+    from app.core.governance.unified_responsibility_repository import (
+        SqlAlchemyUnifiedResponsibilityRepository,
+    )
+
+    engine = create_engine(database_url)
+    marker = uuid.uuid4().hex[:10]
+    users = [str(uuid.uuid4()) for _ in range(3)]
+    actor, owner_a, owner_b = users
+    organization_uid = f"wp06-org-{marker}"
+    domain_a = f"wp06-domain-a-{marker}"
+    domain_b = f"wp06-domain-b-{marker}"
+    object_uids = {
+        object_type: f"wp06-{object_type}-{marker}"
+        for object_type in OBJECT_TYPES
+    }
+    session = Session(engine)
+    try:
+        for index, user_uid in enumerate(users):
+            session.execute(
+                text(
+                    """
+                    INSERT INTO public.users (
+                        id, username, display_name, password_hash, status
+                    ) VALUES (
+                        CAST(:id AS uuid), :username, :display_name,
+                        'not-a-login-secret', 'active'
+                    )
+                    """
+                ),
+                {
+                    "id": user_uid,
+                    "username": f"wp06_{marker}_{index}",
+                    "display_name": f"WP06 user {index}",
+                },
+            )
+        session.commit()
+
+        matrix = ResponsibilityService(SqlAlchemyResponsibilityRepository(session))
+        unified = UnifiedResponsibilityService(
+            SqlAlchemyUnifiedResponsibilityRepository(session),
+            commit=session.commit,
+            rollback=session.rollback,
+        )
+        matrix.replace(
+            resource_type="organization",
+            resource_uid=organization_uid,
+            assignments=[
+                {
+                    "user_id": owner_a,
+                    "responsibility_role": "organization_owner",
+                    "raci_role": "accountable",
+                }
+            ],
+            expected_revision=0,
+            actor_uid=actor,
+        )
+        matrix.replace(
+            resource_type="business_domain",
+            resource_uid=domain_b,
+            assignments=[
+                {
+                    "user_id": owner_b,
+                    "responsibility_role": "domain_owner",
+                    "raci_role": "accountable",
+                }
+            ],
+            expected_revision=0,
+            actor_uid=actor,
+        )
+        session.commit()
+
+        unified.set_parent(
+            {
+                "resource_type": "business_domain",
+                "resource_uid": domain_a,
+                "parent_type": "organization",
+                "parent_uid": organization_uid,
+            },
+            expected_revision=0,
+            actor_uid=actor,
+        )
+        unified.set_parent(
+            {
+                "resource_type": "business_domain",
+                "resource_uid": domain_b,
+                "parent_type": "organization",
+                "parent_uid": organization_uid,
+            },
+            expected_revision=0,
+            actor_uid=actor,
+        )
+        for object_type, object_uid in object_uids.items():
+            unified.set_parent(
+                {
+                    "resource_type": object_type,
+                    "resource_uid": object_uid,
+                    "parent_type": "business_domain",
+                    "parent_uid": domain_a,
+                },
+                expected_revision=0,
+                actor_uid=actor,
+            )
+
+        resolved = {
+            object_type: unified.resolve(object_type, object_uid)
+            for object_type, object_uid in object_uids.items()
+        }
+        assert all(item["status"] == "resolved" for item in resolved.values())
+        assert {
+            item["final_owners"][0]["effective_user_id"]
+            for item in resolved.values()
+        } == {owner_a}
+
+        now = datetime.now(UTC)
+        delegation = unified.create_delegation(
+            {
+                "source_user_uid": owner_a,
+                "delegate_user_uid": owner_b,
+                "scope_type": "business_domain",
+                "scope_uid": domain_a,
+                "responsibility_role": "organization_owner",
+                "delegation_type": "temporary",
+                "starts_at": (now - timedelta(minutes=1)).isoformat(),
+                "ends_at": (now + timedelta(minutes=1)).isoformat(),
+                "reason": "WP06 PostgreSQL acceptance",
+            },
+            actor_uid=actor,
+        )
+        delegated = unified.resolve("data_asset", object_uids["data_asset"])
+        assert delegated["final_owners"][0]["effective_user_id"] == owner_b
+        unified.expire_delegations(
+            at=now + timedelta(minutes=2), actor_uid=actor
+        )
+        assert unified.resolve(
+            "data_asset", object_uids["data_asset"], at=now + timedelta(minutes=2)
+        )["final_owners"][0]["effective_user_id"] == owner_a
+
+        central = unified.create_policy(
+            {
+                "code": f"WP06_CENTRAL_{marker.upper()}",
+                "name": "WP06 central policy",
+                "policy_type": "central_policy",
+                "scope_type": "organization",
+                "scope_uid": organization_uid,
+                "definition": {
+                    "required_responsibility_roles": ["organization_owner"],
+                    "require_unique_accountable": True,
+                    "max_delegation_days": 30,
+                },
+            },
+            actor_uid=actor,
+        )
+        unified.publish_policy(central["uid"], expected_version=1, actor_uid=actor)
+        joint = unified.create_policy(
+            {
+                "code": f"WP06_JOINT_{marker.upper()}",
+                "name": "WP06 joint review",
+                "policy_type": "joint_review",
+                "scope_type": "data_product",
+                "scope_uid": object_uids["data_product"],
+                "definition": {
+                    "business_domain_uids": [domain_a, domain_b],
+                    "min_approvals": 2,
+                    "require_all_domains": True,
+                },
+            },
+            actor_uid=actor,
+        )
+        unified.publish_policy(joint["uid"], expected_version=1, actor_uid=actor)
+        review = unified.evaluate_joint_review(
+            "data_product",
+            object_uids["data_product"],
+            [
+                {"user_uid": owner_a, "domain_uid": domain_a, "decision": "approve"},
+                {"user_uid": owner_b, "domain_uid": domain_b, "decision": "approve"},
+            ],
+        )
+        assert review["status"] == "approved"
+        assert review["deterministic"] is True
+
+        audit_count = session.execute(
+            text(
+                """
+                SELECT COUNT(*)
+                FROM public.governance_responsibility_audit_events
+                WHERE actor_uid = CAST(:actor_uid AS uuid)
+                """
+            ),
+            {"actor_uid": actor},
+        ).scalar_one()
+        assert audit_count >= 14
+        assert delegation["current_version"] == 1
+    finally:
+        session.rollback()
+        session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_policies
+                SET active_version_uid = NULL
+                WHERE created_by = CAST(:actor_uid AS uuid);
+                DELETE FROM public.governance_responsibility_policy_versions
+                WHERE created_by = CAST(:actor_uid AS uuid);
+                DELETE FROM public.governance_responsibility_policies
+                WHERE created_by = CAST(:actor_uid AS uuid);
+                DELETE FROM public.governance_responsibility_delegations
+                WHERE created_by = CAST(:actor_uid AS uuid);
+                DELETE FROM public.governance_responsibility_hierarchy
+                WHERE updated_by = CAST(:actor_uid AS uuid);
+                DELETE FROM public.governance_responsibility_scopes
+                WHERE updated_by = CAST(:actor_uid AS uuid);
+                DELETE FROM public.governance_responsibility_audit_events
+                WHERE actor_uid = CAST(:actor_uid AS uuid);
+                DELETE FROM public.users
+                WHERE id::text = ANY(:user_uids);
+                """
+            ),
+            {"actor_uid": actor, "user_uids": users},
+        )
+        session.commit()
+        session.close()
+        engine.dispose()

+ 22 - 1
tests/test_responsibility_frontend_contract.py

@@ -20,11 +20,32 @@ def test_responsibility_matrix_has_admin_route_and_versioned_client():
     assert "governance:responsibilities:manage" in routes
     assert "getResponsibilityMatrix" in api
     assert "replaceResponsibilityMatrix" in api
+    for operation in (
+        "resolveResponsibility",
+        "replaceResponsibilityParent",
+        "listResponsibilityDelegations",
+        "createResponsibilityDelegation",
+        "revokeResponsibilityDelegation",
+        "transferDepartingResponsibility",
+        "listResponsibilityPolicies",
+        "createResponsibilityPolicy",
+        "reviseResponsibilityPolicy",
+        "publishResponsibilityPolicy",
+        "getResponsibilityOperations",
+        "evaluateJointReview",
+    ):
+        assert operation in api
     assert "'If-Match': `\"${revision}\"`" in api
     assert "put (url, params, config = {})" in request
-    assert "设备责任矩阵" in view
+    assert "统一责任中心" in view
     assert "平台角色决定功能权限" in view
     assert "设备资产管理员" in view
+    assert "资产、术语、标准、质量、产品和 Agent" in view
+    assert "责任继承与覆盖" in view
+    assert "委派与离岗转交" in view
+    assert "中央策略与联合评审" in view
+    assert "责任运营视图" in view
+    assert "不自动获得数据访问权限" in view
     assert "责任类型" in view
     assert "RACI" in view
     assert "accountable" in view

+ 174 - 0
tests/test_unified_responsibility_api.py

@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+USER_A = "01900000-0000-7000-8000-000000006101"
+POLICY_UID = "01900000-0000-7000-8000-000000006102"
+DELEGATION_UID = "01900000-0000-7000-8000-000000006103"
+
+
+class FakeUnifiedService:
+    def __init__(self):
+        self.calls = []
+
+    def resolve(self, resource_type, resource_uid, at=None):
+        self.calls.append(("resolve", resource_type, resource_uid, at))
+        return {
+            "resource_type": resource_type,
+            "resource_uid": resource_uid,
+            "status": "resolved",
+            "final_owners": [{"effective_user_id": USER_A}],
+            "grants_data_access": False,
+        }
+
+    def set_parent(self, payload, expected_revision, actor_uid):
+        self.calls.append(("set_parent", payload, expected_revision, actor_uid))
+        return {**payload, "revision": expected_revision + 1}
+
+    def create_delegation(self, payload, actor_uid):
+        self.calls.append(("create_delegation", payload, actor_uid))
+        return {"uid": DELEGATION_UID, **payload, "current_version": 1}
+
+    def list_delegations(self, status=None):
+        self.calls.append(("list_delegations", status))
+        return []
+
+    def revoke_delegation(self, uid, expected_version, actor_uid):
+        self.calls.append(("revoke", uid, expected_version, actor_uid))
+        return {"uid": uid, "status": "revoked", "current_version": 2}
+
+    def expire_delegations(self, at=None, actor_uid=None):
+        self.calls.append(("expire", at, actor_uid))
+        return []
+
+    def transfer_departing_user(self, payload, actor_uid):
+        self.calls.append(("transfer", payload, actor_uid))
+        return {"uid": DELEGATION_UID, "delegation_type": "departure_transfer"}
+
+    def create_policy(self, payload, actor_uid):
+        self.calls.append(("create_policy", payload, actor_uid))
+        return {"uid": POLICY_UID, **payload, "current_version": 1}
+
+    def list_policies(self):
+        self.calls.append(("list_policies",))
+        return []
+
+    def revise_policy(self, uid, definition, expected_version, actor_uid):
+        self.calls.append(("revise_policy", uid, definition, expected_version, actor_uid))
+        return {"uid": uid, "current_version": expected_version + 1}
+
+    def publish_policy(self, uid, expected_version, actor_uid):
+        self.calls.append(("publish_policy", uid, expected_version, actor_uid))
+        return {"uid": uid, "status": "published", "current_version": expected_version}
+
+    def evaluate_joint_review(self, resource_type, resource_uid, decisions):
+        self.calls.append(("evaluate", resource_type, resource_uid, decisions))
+        return {"status": "approved", "deterministic": True}
+
+    def operations(self, owner_uid):
+        self.calls.append(("operations", owner_uid))
+        return {"owner_uid": owner_uid, "tasks": [], "metrics": [], "grants_data_access": False}
+
+
+def _headers(role: str, **extra):
+    return {"Authorization": f"Bearer {role}", **extra}
+
+
+def _client(monkeypatch):
+    from app import create_app
+    from app.api.system import responsibilities
+
+    service = FakeUnifiedService()
+    monkeypatch.setattr(responsibilities, "_unified_service", lambda: service)
+    monkeypatch.setattr(
+        "app.core.system.auth.load_identity_from_token",
+        lambda token, secret: (
+            {"id": USER_A, "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_effective_owner_is_readable_without_granting_access(monkeypatch):
+    client, service = _client(monkeypatch)
+
+    response = client.get(
+        "/api/system/responsibilities/data_asset/asset-1/resolved",
+        headers=_headers("viewer"),
+    )
+
+    assert response.status_code == 200
+    assert response.get_json()["data"]["status"] == "resolved"
+    assert response.get_json()["data"]["grants_data_access"] is False
+    assert service.calls[-1][:3] == ("resolve", "data_asset", "asset-1")
+
+
+def test_hierarchy_delegation_and_policy_mutations_are_admin_only_and_versioned(monkeypatch):
+    client, service = _client(monkeypatch)
+    hierarchy = {"parent_type": "business_domain", "parent_uid": "domain-1"}
+
+    forbidden = client.put(
+        "/api/system/responsibilities/hierarchy/data_asset/asset-1",
+        json=hierarchy,
+        headers=_headers("editor", **{"If-Match": '"0"'}),
+    )
+    assert forbidden.status_code == 403
+
+    missing = client.put(
+        "/api/system/responsibilities/hierarchy/data_asset/asset-1",
+        json=hierarchy,
+        headers=_headers("admin"),
+    )
+    assert missing.status_code == 428
+
+    updated = client.put(
+        "/api/system/responsibilities/hierarchy/data_asset/asset-1",
+        json=hierarchy,
+        headers=_headers("admin", **{"If-Match": '"0"'}),
+    )
+    assert updated.status_code == 200
+    assert updated.headers["ETag"] == '"1"'
+
+    created = client.post(
+        "/api/system/responsibilities/delegations",
+        json={"delegation_type": "temporary"},
+        headers=_headers("admin"),
+    )
+    assert created.status_code == 201
+
+    revised = client.post(
+        f"/api/system/responsibilities/policies/{POLICY_UID}/revisions",
+        json={"definition": {"require_all_domains": True}},
+        headers=_headers("admin", **{"If-Match": '"1"'}),
+    )
+    assert revised.status_code == 200
+    assert revised.headers["ETag"] == '"2"'
+    assert service.calls[-1][3] == 1
+
+
+def test_read_models_cover_delegations_policies_operations_and_joint_review(monkeypatch):
+    client, service = _client(monkeypatch)
+
+    assert client.get(
+        "/api/system/responsibilities/delegations?status=active",
+        headers=_headers("viewer"),
+    ).status_code == 200
+    assert client.get(
+        "/api/system/responsibilities/policies",
+        headers=_headers("viewer"),
+    ).status_code == 200
+    operations = client.get(
+        f"/api/system/responsibilities/operations?owner_uid={USER_A}",
+        headers=_headers("viewer"),
+    )
+    assert operations.status_code == 200
+    assert operations.get_json()["data"]["grants_data_access"] is False
+    evaluated = client.post(
+        "/api/system/responsibilities/data_product/product-1/joint-review/evaluate",
+        json={"decisions": []},
+        headers=_headers("admin"),
+    )
+    assert evaluated.status_code == 200
+    assert evaluated.get_json()["data"]["deterministic"] is True

+ 45 - 0
tests/test_unified_responsibility_schema.py

@@ -0,0 +1,45 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_unified_responsibility_migration_is_versioned_audited_and_retained():
+    migration = (
+        ROOT
+        / "migrations"
+        / "versions"
+        / "20260801_420_unified_responsibilities.py"
+    ).read_text(encoding="utf-8")
+
+    assert 'revision = "20260801_420"' in migration
+    assert 'down_revision = "20260731_410"' in migration
+    for object_type in (
+        "data_asset",
+        "semantic_term",
+        "data_standard",
+        "quality_policy",
+        "data_product",
+        "agent",
+    ):
+        assert object_type in migration
+    for table in (
+        "governance_responsibility_hierarchy",
+        "governance_responsibility_delegations",
+        "governance_responsibility_policies",
+        "governance_responsibility_policy_versions",
+    ):
+        assert f"CREATE TABLE public.{table}" in migration
+    assert "current_version" in migration
+    assert "active_version_uid" in migration
+    assert "departure_transfer" in migration
+    assert "joint_review" in migration
+    assert "raise RuntimeError" in migration
+    assert "DROP TABLE" not in migration.upper()
+    deployment = (
+        ROOT
+        / "deployment"
+        / "migrations"
+        / "versions"
+        / "20260801_420_unified_responsibilities.py"
+    ).read_text(encoding="utf-8")
+    assert deployment == migration