Browse Source

feat: add unified governance work center

马小龙 2 weeks ago
parent
commit
ae59c5bd4a

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

@@ -9,3 +9,4 @@ from app.api.system import responsibilities  # noqa: E402, F401
 from app.api.system import routes  # noqa: E402, F401
 from app.api.system import users  # noqa: E402, F401
 from app.api.system import workbench  # noqa: E402, F401
+from app.api.system import work_center  # noqa: E402, F401

+ 327 - 0
app/api/system/work_center.py

@@ -0,0 +1,327 @@
+"""Unified governance work-center API."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.system import bp
+from app.core.events.email_delivery import smtp_sender
+from app.core.governance.work_center import UnifiedWorkCenterService
+from app.core.governance.work_center_repository import SqlAlchemyWorkCenterRepository
+from app.core.system.permissions import (
+    WORK_CENTER_MANAGE,
+    WORK_CENTER_OPERATE,
+    WORK_CENTER_READ,
+    permissions_for_roles,
+    require_permissions,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    return UnifiedWorkCenterService(
+        SqlAlchemyWorkCenterRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _expected_version() -> int:
+    raw = str(request.headers.get("If-Match") or "").strip()
+    if raw.startswith("W/"):
+        raw = raw[2:].strip()
+    raw = raw.strip('"')
+    if not raw.isdigit():
+        raise ValueError("missing valid If-Match version")
+    return int(raw)
+
+
+def _etag(response, version):
+    response.headers["ETag"] = f'"{int(version)}"'
+    return response
+
+
+def _error(exc):
+    db.session.rollback()
+    if "If-Match" in str(exc):
+        status = 428
+    elif isinstance(exc, LookupError):
+        status = 404
+    elif isinstance(exc, PermissionError):
+        status = 403
+    elif isinstance(exc, RuntimeError):
+        status = 409
+    else:
+        status = 400
+    return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route("/work-center/workflows", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_workflows():
+    return jsonify(success(_service().list_workflows()))
+
+
+@bp.route("/work-center/workflows", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def create_work_center_workflow():
+    try:
+        result = _service().create_workflow(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "流程草稿已创建", code=201)), 1), 201
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/workflows/<workflow_uid>/revisions", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def revise_work_center_workflow(workflow_uid):
+    try:
+        result = _service().revise_workflow(
+            workflow_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "流程版本已创建")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/workflows/<workflow_uid>/publish", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def publish_work_center_workflow(workflow_uid):
+    try:
+        result = _service().publish_workflow(
+            workflow_uid,
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "流程版本已发布")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_tasks():
+    permissions = permissions_for_roles(g.current_user.get("roles", []))
+    filters = {
+        key: request.args.get(key)
+        for key in ("status", "task_type", "subject_type", "assignee_uid")
+        if request.args.get(key)
+    }
+    filters.update(
+        {
+            "requester_uid": g.current_user["id"],
+            "can_manage": WORK_CENTER_MANAGE in permissions,
+        }
+    )
+    return jsonify(success(_service().list_tasks(**filters)))
+
+
+@bp.route("/work-center/tasks", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def create_work_center_task():
+    try:
+        result = _service().create_task(
+            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, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks/<task_uid>", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def get_work_center_task(task_uid):
+    try:
+        permissions = permissions_for_roles(g.current_user.get("roles", []))
+        result = _service().task_detail(
+            task_uid,
+            requester_uid=g.current_user["id"],
+            can_manage=WORK_CENTER_MANAGE in permissions,
+        )
+        return _etag(jsonify(success(result)), result["current_version"])
+    except (ValueError, LookupError) as exc:
+        return _error(exc)
+
+
+def _task_action(task_uid, action):
+    service = _service()
+    body = request.get_json(silent=True) or {}
+    arguments = {
+        "expected_version": _expected_version(),
+        "actor_uid": g.current_user["id"],
+    }
+    if action == "review":
+        return service.review_task(task_uid, body, **arguments)
+    if action == "transfer":
+        return service.transfer_review(task_uid, body, **arguments)
+    if action == "close":
+        return service.close_task(task_uid, body, **arguments)
+    return service.reopen_task(task_uid, body, **arguments)
+
+
+@bp.route("/work-center/tasks/<task_uid>/<action>", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def operate_work_center_task(task_uid, action):
+    if action not in {"review", "transfer", "close", "reopen"}:
+        return jsonify(failed("unsupported task action", code=404)), 404
+    try:
+        result = _task_action(task_uid, action)
+        return _etag(jsonify(success(result, "任务状态已更新")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks/<task_uid>/comments", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def add_work_center_comment(task_uid):
+    try:
+        result = _service().add_comment(
+            task_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "评论已添加", code=201)), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks/<task_uid>/attachments", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def add_work_center_attachment(task_uid):
+    try:
+        result = _service().add_attachment(
+            task_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "附件证据已登记", code=201)), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/timeouts/process", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def process_work_center_timeouts():
+    try:
+        body = request.get_json(silent=True) or {}
+        return jsonify(
+            success(
+                _service().process_timeouts(
+                    at=body.get("at"), actor_uid=g.current_user["id"]
+                ),
+                "逾期任务已处理",
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/notifications", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_notifications():
+    return jsonify(
+        success(
+            _service().list_notifications(
+                g.current_user["id"],
+                unread_only=request.args.get("unread_only") == "true",
+            )
+        )
+    )
+
+
+@bp.route("/work-center/notifications/<notification_uid>/read", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def read_work_center_notification(notification_uid):
+    try:
+        return jsonify(
+            success(
+                _service().mark_notification_read(
+                    notification_uid, actor_uid=g.current_user["id"]
+                ),
+                "消息已读",
+            )
+        )
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/notifications/deliver", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def deliver_work_center_notifications():
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _service().deliver_notifications(
+            "email", smtp_sender, at=body.get("at"), limit=body.get("limit", 50)
+        )
+        return jsonify(success(result, "邮件投递批次已处理"))
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/templates", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_templates():
+    return jsonify(success(_service().list_notification_templates()))
+
+
+@bp.route("/work-center/templates", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def create_work_center_template():
+    try:
+        result = _service().create_notification_template(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "通知模板已创建", code=201)), 1), 201
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/templates/<template_uid>", methods=["PATCH"])
+@require_permissions(WORK_CENTER_MANAGE)
+def revise_work_center_template(template_uid):
+    try:
+        result = _service().revise_notification_template(
+            template_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "通知模板已更新")), result["current_version"])
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/preferences", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def get_work_center_preferences():
+    result = _service().get_notification_preferences(g.current_user["id"])
+    return _etag(jsonify(success(result)), result["revision"])
+
+
+@bp.route("/work-center/preferences", methods=["PUT"])
+@require_permissions(WORK_CENTER_OPERATE)
+def replace_work_center_preferences():
+    try:
+        result = _service().replace_notification_preferences(
+            request.get_json(silent=True) or {},
+            expected_revision=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "通知偏好已更新")), result["revision"])
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/dashboard", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def get_work_center_dashboard():
+    try:
+        return jsonify(success(_service().dashboard(at=request.args.get("at"))))
+    except ValueError as exc:
+        return _error(exc)

+ 30 - 0
app/core/events/email_delivery.py

@@ -0,0 +1,30 @@
+"""Small SMTP binding for work-center email delivery."""
+
+from __future__ import annotations
+
+import os
+import smtplib
+from email.message import EmailMessage
+
+
+def smtp_sender(notification: dict) -> None:
+    """Deliver one notification; configuration failures remain retryable evidence."""
+    host = os.getenv("DATAOPS_SMTP_HOST")
+    sender = os.getenv("DATAOPS_SMTP_FROM")
+    recipient = notification.get("recipient_email")
+    if not host or not sender or not recipient:
+        raise RuntimeError("SMTP binding is incomplete")
+    port = int(os.getenv("DATAOPS_SMTP_PORT", "587"))
+    message = EmailMessage()
+    message["From"] = sender
+    message["To"] = recipient
+    message["Subject"] = notification["subject"]
+    message.set_content(notification["body"])
+    with smtplib.SMTP(host, port, timeout=10) as client:
+        if os.getenv("DATAOPS_SMTP_STARTTLS", "true").lower() == "true":
+            client.starttls()
+        username = os.getenv("DATAOPS_SMTP_USERNAME")
+        password = os.getenv("DATAOPS_SMTP_PASSWORD")
+        if username and password:
+            client.login(username, password)
+        client.send_message(message)

+ 1070 - 0
app/core/governance/work_center.py

@@ -0,0 +1,1070 @@
+"""Thin unified work contract over existing governance source state machines."""
+
+from __future__ import annotations
+
+import copy
+import re
+import uuid
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+
+SUBJECT_TYPES = frozenset(
+    {"quality_issue", "semantic_governance", "data_product", "agent"}
+)
+TASK_TYPES = frozenset(
+    {
+        "approval",
+        "quality_issue",
+        "semantic_governance",
+        "data_product_approval",
+        "agent_approval",
+        "governance_work_order",
+        "release",
+        "high_risk",
+    }
+)
+APPROVAL_MODES = frozenset({"single", "any", "all", "dual_control"})
+PRIORITIES = frozenset({"low", "medium", "high", "critical"})
+DECISIONS = frozenset({"approve", "reject"})
+TIMEOUT_ACTIONS = frozenset({"escalate", "close"})
+NOTIFICATION_CHANNELS = frozenset({"in_app", "email"})
+TEMPLATE_STATUSES = frozenset({"active", "retired"})
+CONDITION_FIELDS = frozenset(
+    {
+        "business_domain_uid",
+        "risk_level",
+        "sensitivity_level",
+        "environment",
+        "amount",
+    }
+)
+CONDITION_OPERATORS = frozenset({"eq", "in", "gte", "lte"})
+ACTIVE_TASK_STATUSES = frozenset(
+    {"pending", "in_progress", "pending_review", "reopened"}
+)
+CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,119}$")
+HASH_PATTERN = re.compile(r"^[0-9a-f]{64}$")
+
+
+@dataclass(frozen=True)
+class DeliveryResult:
+    status: str
+    attempts: int
+    available_at: datetime | None = None
+    last_error: str | None = None
+
+
+def _closed(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise ValueError(f"{label} must be an object")
+    unknown = sorted(set(value) - allowed)
+    if unknown:
+        raise ValueError(f"{label} contains unsupported fields: {', '.join(unknown)}")
+    return copy.deepcopy(value)
+
+
+def _string(value: Any, label: str, maximum: int = 1000) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    result = value.strip()
+    if len(result) > maximum:
+        raise ValueError(f"{label} exceeds {maximum} characters")
+    return result
+
+
+def _optional_string(value: Any, label: str, maximum: int = 1000):
+    if value is None:
+        return None
+    return _string(value, label, maximum)
+
+
+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 _list(value: Any, label: str, *, minimum: int = 0) -> list[Any]:
+    if not isinstance(value, list) or len(value) < minimum:
+        raise ValueError(f"{label} must contain at least {minimum} items")
+    return copy.deepcopy(value)
+
+
+def _normalize_condition(value: Any) -> dict[str, Any]:
+    item = _closed(value, {"field", "operator", "value"}, "route condition")
+    field = _string(item.get("field"), "condition field", 50)
+    operator = _string(item.get("operator"), "condition operator", 10)
+    if field not in CONDITION_FIELDS or operator not in CONDITION_OPERATORS:
+        raise ValueError("unsupported route condition")
+    condition_value = item.get("value")
+    if operator == "in" and not isinstance(condition_value, list):
+        raise ValueError("in condition requires an array value")
+    if operator in {"gte", "lte"}:
+        try:
+            condition_value = float(condition_value)
+        except (TypeError, ValueError) as error:
+            raise ValueError("numeric route condition is invalid") from error
+    return {"field": field, "operator": operator, "value": condition_value}
+
+
+def _normalize_route(value: Any, *, priority: int = 9999) -> dict[str, Any]:
+    route = _closed(
+        value,
+        {
+            "priority",
+            "conditions",
+            "approval_mode",
+            "reviewer_uids",
+            "min_approvals",
+            "due_hours",
+            "timeout_action",
+            "notification_channels",
+        },
+        "workflow route",
+    )
+    try:
+        normalized_priority = int(route.get("priority", priority))
+        minimum = int(route.get("min_approvals", 1))
+        due_hours = int(route.get("due_hours", 24))
+    except (TypeError, ValueError) as error:
+        raise ValueError("workflow route numeric field is invalid") from error
+    if normalized_priority < 0 or due_hours < 1 or due_hours > 8760:
+        raise ValueError("workflow route priority or due time is invalid")
+    approval_mode = _string(route.get("approval_mode"), "approval_mode", 30)
+    if approval_mode not in APPROVAL_MODES:
+        raise ValueError("unsupported approval mode")
+    reviewers = sorted(
+        {
+            _uid(item, "reviewer uid")
+            for item in _list(route.get("reviewer_uids"), "reviewer_uids", minimum=1)
+        }
+    )
+    if minimum < 1 or minimum > len(reviewers):
+        raise ValueError("min approvals is outside reviewer range")
+    if approval_mode == "single" and (len(reviewers) != 1 or minimum != 1):
+        raise ValueError("single approval requires exactly one reviewer")
+    if approval_mode == "all" and minimum != len(reviewers):
+        raise ValueError("all approval requires every reviewer")
+    if approval_mode == "dual_control" and (len(reviewers) < 2 or minimum < 2):
+        raise ValueError("dual control requires two independent reviewers")
+    timeout_action = _string(
+        route.get("timeout_action", "escalate"), "timeout_action", 20
+    )
+    if timeout_action not in TIMEOUT_ACTIONS:
+        raise ValueError("unsupported timeout action")
+    channels = sorted(
+        {
+            _string(item, "notification channel", 20)
+            for item in route.get("notification_channels", ["in_app"])
+        }
+    )
+    if not channels or not set(channels) <= NOTIFICATION_CHANNELS:
+        raise ValueError("unsupported notification channel")
+    return {
+        "priority": normalized_priority,
+        "conditions": [
+            _normalize_condition(item) for item in route.get("conditions", [])
+        ],
+        "approval_mode": approval_mode,
+        "reviewer_uids": reviewers,
+        "min_approvals": minimum,
+        "due_hours": due_hours,
+        "timeout_action": timeout_action,
+        "notification_channels": channels,
+    }
+
+
+def _matches(route: dict[str, Any], context: dict[str, Any]) -> bool:
+    for condition in route["conditions"]:
+        actual = context.get(condition["field"])
+        expected = condition["value"]
+        operator = condition["operator"]
+        if operator == "eq" and actual != expected:
+            return False
+        if operator == "in" and actual not in expected:
+            return False
+        if operator in {"gte", "lte"}:
+            try:
+                numeric = float(actual)
+            except (TypeError, ValueError):
+                return False
+            if operator == "gte" and numeric < expected:
+                return False
+            if operator == "lte" and numeric > expected:
+                return False
+    return True
+
+
+def _redact_error(error: Exception) -> str:
+    message = re.sub(
+        r"(?i)(secret[-_ ]?token|api[-_ ]?key|authorization|bearer)(?:[=: ]+\S+)?",
+        "[redacted]",
+        str(error),
+    )
+    return message[:1000]
+
+
+class UnifiedWorkCenterService:
+    """Coordinate work evidence while source modules remain authoritative."""
+
+    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 create_workflow(self, payload: Any, *, actor_uid: str) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {"code", "name", "subject_types", "routes", "default_route"},
+            "workflow definition",
+        )
+        code = _string(body.get("code"), "code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("workflow code is invalid")
+        subject_types = sorted(
+            {
+                _string(item, "subject type", 40)
+                for item in _list(
+                    body.get("subject_types"), "subject_types", minimum=1
+                )
+            }
+        )
+        if not set(subject_types) <= SUBJECT_TYPES:
+            raise ValueError("unsupported workflow subject type")
+        routes = sorted(
+            [_normalize_route(item) for item in body.get("routes", [])],
+            key=lambda item: item["priority"],
+        )
+        default_route = _normalize_route(body.get("default_route"), priority=9999)
+        users = {
+            user
+            for route in [*routes, default_route]
+            for user in route["reviewer_uids"]
+        }
+        if self.repository.users_available(users) != users:
+            raise ValueError("workflow reviewer is unknown or disabled")
+        actor = _uid(actor_uid, "actor_uid")
+        now = self.now_factory().isoformat()
+        workflow_uid = self.uid_factory()
+        version_uid = self.uid_factory()
+        workflow = {
+            "uid": workflow_uid,
+            "code": code,
+            "name": _string(body.get("name"), "name", 300),
+            "status": "draft",
+            "current_version": 1,
+            "active_version_uid": None,
+            "created_by": actor,
+            "created_at": now,
+            "updated_at": now,
+        }
+        version = {
+            "uid": version_uid,
+            "workflow_uid": workflow_uid,
+            "version": 1,
+            "status": "draft",
+            "definition": {
+                "subject_types": subject_types,
+                "routes": routes,
+                "default_route": default_route,
+            },
+            "created_by": actor,
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        try:
+            self.repository.create_workflow(workflow, version)
+            self.commit()
+            return {**workflow, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def publish_workflow(
+        self, workflow_uid: str, *, expected_version: int, actor_uid: str
+    ) -> dict[str, Any]:
+        uid = _uid(workflow_uid, "workflow_uid")
+        workflow = self.repository.get_workflow(uid)
+        if workflow is None:
+            raise LookupError("workflow was not found")
+        if int(workflow["current_version"]) != int(expected_version):
+            raise RuntimeError("workflow version conflict")
+        version = self.repository.workflow_version(uid, int(expected_version))
+        if version is None or version["status"] != "draft":
+            raise RuntimeError("workflow 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 = {
+            **workflow,
+            "status": "published",
+            "active_version_uid": version["uid"],
+            "updated_at": now,
+        }
+        try:
+            self.repository.publish_workflow(
+                published, published_version, int(expected_version)
+            )
+            self.commit()
+            return {**published, "active_version": published_version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def revise_workflow(
+        self,
+        workflow_uid: str,
+        payload: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {"subject_types", "routes", "default_route"},
+            "workflow revision",
+        )
+        uid = _uid(workflow_uid, "workflow_uid")
+        workflow = self.repository.get_workflow(uid)
+        if workflow is None:
+            raise LookupError("workflow was not found")
+        if int(workflow["current_version"]) != int(expected_version):
+            raise RuntimeError("workflow version conflict")
+        subject_types = sorted(
+            {
+                _string(item, "subject type", 40)
+                for item in _list(body.get("subject_types"), "subject_types", minimum=1)
+            }
+        )
+        if not set(subject_types) <= SUBJECT_TYPES:
+            raise ValueError("unsupported workflow subject type")
+        routes = sorted(
+            [_normalize_route(item) for item in body.get("routes", [])],
+            key=lambda item: item["priority"],
+        )
+        default_route = _normalize_route(body.get("default_route"), priority=9999)
+        reviewers = {
+            reviewer
+            for route in [*routes, default_route]
+            for reviewer in route["reviewer_uids"]
+        }
+        if self.repository.users_available(reviewers) != reviewers:
+            raise ValueError("workflow reviewer is unknown or disabled")
+        now = self.now_factory().isoformat()
+        actor = _uid(actor_uid, "actor_uid")
+        next_version = int(expected_version) + 1
+        revised = {
+            **workflow,
+            "status": "published" if workflow.get("active_version_uid") else "draft",
+            "current_version": next_version,
+            "updated_at": now,
+        }
+        version = {
+            "uid": self.uid_factory(),
+            "workflow_uid": uid,
+            "version": next_version,
+            "status": "draft",
+            "definition": {
+                "subject_types": subject_types,
+                "routes": routes,
+                "default_route": default_route,
+            },
+            "created_by": actor,
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        try:
+            self.repository.revise_workflow(revised, version, int(expected_version))
+            self.commit()
+            return {**revised, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_workflows(self) -> list[dict[str, Any]]:
+        return self.repository.list_workflows()
+
+    def _published_definition(self, workflow_uid: str):
+        workflow = self.repository.get_workflow(_uid(workflow_uid, "workflow_uid"))
+        if workflow is None or workflow["status"] != "published":
+            raise LookupError("published workflow was not found")
+        if workflow.get("active_version_uid") and hasattr(
+            self.repository, "workflow_version_by_uid"
+        ):
+            version = self.repository.workflow_version_by_uid(
+                workflow["active_version_uid"]
+            )
+        else:
+            version = self.repository.workflow_version(
+                workflow["uid"], int(workflow["current_version"])
+            )
+        if version is None or version["status"] != "published":
+            raise RuntimeError("active workflow version is unavailable")
+        return workflow, version
+
+    def _select_route(self, definition, context):
+        return next(
+            (
+                copy.deepcopy(route)
+                for route in definition["routes"]
+                if _matches(route, context)
+            ),
+            copy.deepcopy(definition["default_route"]),
+        )
+
+    def create_task(self, payload: Any, *, actor_uid: str) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {
+                "workflow_uid",
+                "task_type",
+                "subject_type",
+                "subject_uid",
+                "source_type",
+                "source_uid",
+                "title",
+                "description",
+                "priority",
+                "business_domain_uid",
+                "context",
+            },
+            "unified task",
+        )
+        workflow, version = self._published_definition(body.get("workflow_uid"))
+        task_type = _string(body.get("task_type"), "task_type", 40)
+        subject_type = _string(body.get("subject_type"), "subject_type", 40)
+        if task_type not in TASK_TYPES or subject_type not in SUBJECT_TYPES:
+            raise ValueError("unsupported task or subject type")
+        if subject_type not in version["definition"]["subject_types"]:
+            raise ValueError("workflow does not support the subject type")
+        context = body.get("context") or {}
+        if not isinstance(context, dict):
+            raise ValueError("task context must be an object")
+        route = self._select_route(version["definition"], context)
+        if self.repository.users_available(route["reviewer_uids"]) != set(
+            route["reviewer_uids"]
+        ):
+            raise ValueError("task reviewer is unknown or disabled")
+        actor = _uid(actor_uid, "actor_uid")
+        now = self.now_factory()
+        task_uid = self.uid_factory()
+        task = {
+            "uid": task_uid,
+            "task_code": f"GWT-{now:%Y%m%d}-{task_uid[-8:].upper()}",
+            "workflow_uid": workflow["uid"],
+            "workflow_version": int(version["version"]),
+            "task_type": task_type,
+            "subject_type": subject_type,
+            "subject_uid": _string(body.get("subject_uid"), "subject_uid", 200),
+            "source_type": _string(body.get("source_type"), "source_type", 80),
+            "source_uid": _string(body.get("source_uid"), "source_uid", 200),
+            "title": _string(body.get("title"), "title", 300),
+            "description": _string(body.get("description"), "description", 2000),
+            "business_domain_uid": _optional_string(
+                body.get("business_domain_uid"), "business_domain_uid", 200
+            ),
+            "priority": _string(body.get("priority"), "priority", 20),
+            "status": "pending",
+            "assignee_uid": route["reviewer_uids"][0],
+            "due_at": (now + timedelta(hours=route["due_hours"])).isoformat(),
+            "escalation_level": 0,
+            "current_version": 1,
+            "route_snapshot": route,
+            "context": copy.deepcopy(context),
+            "source_state_unchanged": True,
+            "created_by": actor,
+            "created_at": now.isoformat(),
+            "updated_by": actor,
+            "updated_at": now.isoformat(),
+            "closed_at": None,
+        }
+        if task["priority"] not in PRIORITIES:
+            raise ValueError("unsupported task priority")
+        participants = [
+            {
+                "uid": self.uid_factory(),
+                "task_uid": task_uid,
+                "user_uid": reviewer,
+                "sequence": index + 1,
+                "status": "pending",
+                "transferred_from_uid": None,
+                "created_at": now.isoformat(),
+            }
+            for index, reviewer in enumerate(route["reviewer_uids"])
+        ]
+        try:
+            saved = self.repository.create_task(task, participants)
+            created = saved.pop("_created", True)
+            if created:
+                self._notify_task(saved, participants, event_type="task_created")
+            self.commit()
+            return saved
+        except Exception:
+            self.rollback()
+            raise
+
+    def _notify_task(self, task, participants, *, event_type):
+        now = self.now_factory()
+        for recipient in sorted({item["user_uid"] for item in participants}):
+            for channel in task["route_snapshot"]["notification_channels"]:
+                notification_uid = self.uid_factory()
+                record = {
+                        "uid": notification_uid,
+                        "event_key": f"{event_type}:{task['uid']}:{recipient}:{channel}",
+                        "event_type": event_type,
+                        "recipient_uid": recipient,
+                        "channel": channel,
+                        "subject": task["title"],
+                        "body": task["description"],
+                        "related_task_uid": task["uid"],
+                        "status": "delivered" if channel == "in_app" else "pending",
+                        "attempts": 0,
+                        "max_attempts": 5,
+                        "next_attempt_at": now.isoformat(),
+                        "last_error": None,
+                        "delivered_at": now.isoformat() if channel == "in_app" else None,
+                        "read_at": None,
+                        "created_at": now.isoformat(),
+                    }
+                if hasattr(self.repository, "prepare_notification"):
+                    record = self.repository.prepare_notification(record, task)
+                self.repository.create_notification(record)
+
+    def list_tasks(self, **filters) -> list[dict[str, Any]]:
+        return self.repository.list_tasks(**filters)
+
+    def task_detail(
+        self,
+        uid: str,
+        *,
+        requester_uid: str | None = None,
+        can_manage: bool = False,
+    ) -> dict[str, Any]:
+        result = self.repository.task_detail(_uid(uid, "task_uid"))
+        if result is None:
+            raise LookupError("task was not found")
+        if requester_uid and not can_manage:
+            actor = _uid(requester_uid, "requester_uid")
+            visible = {result["created_by"], result["assignee_uid"]} | {
+                item["user_uid"] for item in result.get("participants", [])
+            }
+            if actor not in visible:
+                raise LookupError("task was not found")
+        return result
+
+    def _require_task_collaborator(self, task_uid: str, actor_uid: str):
+        task = self.repository.get_task(task_uid)
+        if task is None:
+            raise LookupError("task was not found")
+        participant_uids = {
+            item["user_uid"] for item in self.repository.participants_for_task(task_uid)
+        }
+        if actor_uid not in {
+            task["created_by"],
+            task["assignee_uid"],
+            *participant_uids,
+        }:
+            raise PermissionError("actor is not a task collaborator")
+        return task
+
+    def review_task(
+        self,
+        task_uid: str,
+        payload: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed(payload, {"decision", "reason"}, "task review")
+        uid = _uid(task_uid, "task_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        task = self.repository.get_task(uid)
+        if task is None:
+            raise LookupError("task was not found")
+        if task["status"] not in ACTIVE_TASK_STATUSES:
+            raise RuntimeError("task is not reviewable")
+        participants = self.repository.participants_for_task(uid)
+        if not any(
+            item["user_uid"] == actor and item["status"] == "pending"
+            for item in participants
+        ):
+            raise PermissionError("reviewer is not a pending participant")
+        mode = task["route_snapshot"]["approval_mode"]
+        if mode == "dual_control" and actor == task["created_by"]:
+            raise PermissionError("dual control creator cannot review")
+        decision = _string(body.get("decision"), "decision", 20)
+        if decision not in DECISIONS:
+            raise ValueError("unsupported review decision")
+        now = self.now_factory().isoformat()
+        self.repository.add_review(
+            {
+                "uid": self.uid_factory(),
+                "task_uid": uid,
+                "reviewer_uid": actor,
+                "decision": decision,
+                "reason": _string(body.get("reason"), "reason", 1000),
+                "created_at": now,
+            }
+        )
+        reviews = self.repository.reviews_for_task(uid)
+        approvals = {item["reviewer_uid"] for item in reviews if item["decision"] == "approve"}
+        if any(item["decision"] == "reject" for item in reviews):
+            status = "rejected"
+        elif mode == "all":
+            status = "approved" if len(approvals) == len(participants) else "pending"
+        else:
+            status = (
+                "approved"
+                if len(approvals) >= int(task["route_snapshot"]["min_approvals"])
+                else "pending"
+            )
+        task.update({"status": status, "updated_by": actor, "updated_at": now})
+        try:
+            result = self.repository.update_task(
+                task,
+                int(expected_version),
+                "reviewed",
+                actor,
+                {"decision": decision, "resulting_status": status},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def transfer_review(
+        self,
+        task_uid: str,
+        payload: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {"source_user_uid", "target_user_uid", "reason"},
+            "review transfer",
+        )
+        uid = _uid(task_uid, "task_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        source = _uid(body.get("source_user_uid"), "source_user_uid")
+        target = _uid(body.get("target_user_uid"), "target_user_uid")
+        if actor != source:
+            raise PermissionError("only the pending reviewer can transfer")
+        if source == target or self.repository.users_available([target]) != {target}:
+            raise ValueError("transfer target is invalid or unavailable")
+        task = self.repository.get_task(uid)
+        if task is None:
+            raise LookupError("task was not found")
+        now = self.now_factory().isoformat()
+        try:
+            self.repository.replace_participant(uid, source, target, actor)
+            task.update(
+                {"assignee_uid": target, "updated_by": actor, "updated_at": now}
+            )
+            result = self.repository.update_task(
+                task,
+                int(expected_version),
+                "review_transferred",
+                actor,
+                {"source_user_uid": source, "target_user_uid": target, "reason": _string(body.get("reason"), "reason", 500)},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def add_comment(self, task_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(payload, {"content", "mentions"}, "task comment")
+        uid = _uid(task_uid, "task_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        self._require_task_collaborator(uid, actor)
+        mentions = sorted(
+            {_uid(item, "mention uid") for item in body.get("mentions", [])}
+        )
+        if self.repository.users_available(mentions) != set(mentions):
+            raise ValueError("mentioned user is unavailable")
+        record = {
+            "uid": self.uid_factory(),
+            "task_uid": uid,
+            "content": _string(body.get("content"), "content", 4000),
+            "mentions": mentions,
+            "created_by": actor,
+            "created_at": self.now_factory().isoformat(),
+        }
+        try:
+            result = self.repository.add_comment(record)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def add_attachment(self, task_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"name", "storage_ref", "content_hash"},
+            "task attachment",
+        )
+        uid = _uid(task_uid, "task_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        self._require_task_collaborator(uid, actor)
+        content_hash = _string(body.get("content_hash"), "content_hash", 64).lower()
+        if not HASH_PATTERN.fullmatch(content_hash):
+            raise ValueError("attachment content hash is invalid")
+        record = {
+            "uid": self.uid_factory(),
+            "task_uid": uid,
+            "name": _string(body.get("name"), "name", 300),
+            "storage_ref": _string(body.get("storage_ref"), "storage_ref", 1000),
+            "content_hash": content_hash,
+            "created_by": actor,
+            "created_at": self.now_factory().isoformat(),
+        }
+        try:
+            result = self.repository.add_attachment(record)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def _transition_task(
+        self,
+        task_uid,
+        payload,
+        *,
+        expected_version,
+        actor_uid,
+        action,
+    ):
+        uid = _uid(task_uid, "task_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        task = self.repository.get_task(uid)
+        if task is None:
+            raise LookupError("task was not found")
+        participant_uids = {
+            item["user_uid"] for item in self.repository.participants_for_task(uid)
+        }
+        if actor not in {task["created_by"], task["assignee_uid"], *participant_uids}:
+            raise PermissionError("actor is not a task collaborator")
+        now = self.now_factory().isoformat()
+        if action == "closed":
+            body = _closed(payload, {"resolution", "evidence_refs"}, "task closure")
+            evidence = _list(body.get("evidence_refs"), "evidence_refs", minimum=1)
+            task.update({"status": "closed", "closed_at": now})
+            audit_payload = {
+                "resolution": _string(body.get("resolution"), "resolution", 2000),
+                "evidence_refs": [_string(item, "evidence ref", 1000) for item in evidence],
+            }
+        else:
+            if task["status"] != "closed":
+                raise RuntimeError("only a closed task can be reopened")
+            body = _closed(payload, {"reason"}, "task reopening")
+            task.update({"status": "reopened", "closed_at": None})
+            audit_payload = {"reason": _string(body.get("reason"), "reason", 1000)}
+        task.update({"updated_by": actor, "updated_at": now})
+        try:
+            result = self.repository.update_task(
+                task,
+                int(expected_version),
+                action,
+                actor,
+                audit_payload,
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def close_task(self, task_uid, payload, *, expected_version, actor_uid):
+        return self._transition_task(
+            task_uid,
+            payload,
+            expected_version=expected_version,
+            actor_uid=actor_uid,
+            action="closed",
+        )
+
+    def reopen_task(self, task_uid, payload, *, expected_version, actor_uid):
+        return self._transition_task(
+            task_uid,
+            payload,
+            expected_version=expected_version,
+            actor_uid=actor_uid,
+            action="reopened",
+        )
+
+    def process_timeouts(
+        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()
+        actor = _uid(actor_uid, "actor_uid")
+        results = []
+        try:
+            for task in self.repository.overdue_tasks(timestamp):
+                action = task["route_snapshot"]["timeout_action"]
+                if action == "close":
+                    task.update({"status": "closed", "closed_at": timestamp.isoformat()})
+                    event = "timeout_closed"
+                else:
+                    task["escalation_level"] = int(task["escalation_level"]) + 1
+                    task["priority"] = "critical"
+                    task["due_at"] = (
+                        timestamp
+                        + timedelta(hours=int(task["route_snapshot"]["due_hours"]))
+                    ).isoformat()
+                    event = "timeout_escalated"
+                task.update(
+                    {
+                        "updated_by": actor,
+                        "updated_at": timestamp.isoformat(),
+                    }
+                )
+                results.append(
+                    self.repository.update_task(
+                        task,
+                        int(task["current_version"]),
+                        event,
+                        actor,
+                        {"processed_at": timestamp.isoformat()},
+                    )
+                )
+            self.commit()
+            return results
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_notifications(self, owner_uid: str, *, unread_only: bool = False):
+        return self.repository.list_notifications(
+            _uid(owner_uid, "owner_uid"), unread_only=bool(unread_only)
+        )
+
+    def deliver_notifications(
+        self,
+        channel: str,
+        adapter: Callable[[dict[str, Any]], None],
+        *,
+        at: datetime | str | None = None,
+        limit: int = 50,
+    ) -> list[dict[str, Any]]:
+        normalized_channel = _string(channel, "channel", 20)
+        if normalized_channel != "email":
+            raise ValueError("only email requires external delivery")
+        timestamp = _time(at, "at") if at is not None else self.now_factory()
+        records = self.repository.claim_notifications(
+            normalized_channel, timestamp, max(1, min(int(limit), 200))
+        )
+        results = []
+        try:
+            for notification in records:
+                attempts = int(notification["attempts"]) + 1
+                try:
+                    adapter(notification)
+                    result = DeliveryResult(status="delivered", attempts=attempts)
+                except Exception as error:
+                    if attempts >= int(notification["max_attempts"]):
+                        result = DeliveryResult(
+                            status="dead_letter",
+                            attempts=attempts,
+                            last_error=_redact_error(error),
+                        )
+                    else:
+                        result = DeliveryResult(
+                            status="pending",
+                            attempts=attempts,
+                            available_at=timestamp
+                            + timedelta(seconds=min(300, 2**attempts)),
+                            last_error=_redact_error(error),
+                        )
+                results.append(
+                    self.repository.record_delivery(notification, result, timestamp)
+                )
+            self.commit()
+            return results
+        except Exception:
+            self.rollback()
+            raise
+
+    def mark_notification_read(self, uid: str, *, actor_uid: str):
+        try:
+            result = self.repository.mark_notification_read(
+                _uid(uid, "notification_uid"),
+                _uid(actor_uid, "actor_uid"),
+                self.now_factory(),
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_notification_templates(self):
+        return self.repository.list_notification_templates()
+
+    def create_notification_template(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"code", "channel", "subject_template", "body_template"},
+            "notification template",
+        )
+        code = _string(body.get("code"), "code", 120).lower()
+        if not re.fullmatch(r"[a-z][a-z0-9_.-]{2,119}", code):
+            raise ValueError("notification template code is invalid")
+        channel = _string(body.get("channel"), "channel", 20)
+        if channel not in NOTIFICATION_CHANNELS:
+            raise ValueError("unsupported notification channel")
+        now = self.now_factory().isoformat()
+        actor = _uid(actor_uid, "actor_uid")
+        record = {
+            "uid": self.uid_factory(),
+            "code": code,
+            "channel": channel,
+            "subject_template": _string(
+                body.get("subject_template"), "subject_template", 500
+            ),
+            "body_template": _string(
+                body.get("body_template"), "body_template", 4000
+            ),
+            "status": "active",
+            "current_version": 1,
+            "created_by": actor,
+            "updated_by": actor,
+            "created_at": now,
+            "updated_at": now,
+        }
+        try:
+            result = self.repository.create_notification_template(record)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def revise_notification_template(
+        self,
+        uid: str,
+        payload: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        body = _closed(
+            payload,
+            {"subject_template", "body_template", "status"},
+            "notification template revision",
+        )
+        status = _string(body.get("status"), "status", 20)
+        if status not in TEMPLATE_STATUSES:
+            raise ValueError("unsupported notification template status")
+        record = {
+            "uid": _uid(uid, "template_uid"),
+            "subject_template": _string(
+                body.get("subject_template"), "subject_template", 500
+            ),
+            "body_template": _string(
+                body.get("body_template"), "body_template", 4000
+            ),
+            "status": status,
+            "updated_by": _uid(actor_uid, "actor_uid"),
+            "updated_at": self.now_factory().isoformat(),
+        }
+        try:
+            result = self.repository.revise_notification_template(
+                record, int(expected_version)
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def get_notification_preferences(self, user_uid: str):
+        return self.repository.get_notification_preferences(_uid(user_uid, "user_uid"))
+
+    def replace_notification_preferences(
+        self,
+        payload: Any,
+        *,
+        expected_revision: int,
+        actor_uid: str,
+    ):
+        body = _closed(
+            payload,
+            {"enabled_channels", "subscribed_events", "quiet_hours"},
+            "notification preferences",
+        )
+        channels = sorted(
+            {_string(item, "channel", 20) for item in body.get("enabled_channels", [])}
+        )
+        if not set(channels) <= NOTIFICATION_CHANNELS:
+            raise ValueError("unsupported notification channel")
+        events = sorted(
+            {
+                _string(item, "subscribed event", 80)
+                for item in body.get("subscribed_events", [])
+            }
+        )
+        quiet_hours = body.get("quiet_hours") or {}
+        if not isinstance(quiet_hours, dict):
+            raise ValueError("quiet_hours must be an object")
+        record = {
+            "user_uid": _uid(actor_uid, "actor_uid"),
+            "enabled_channels": channels,
+            "subscribed_events": events,
+            "quiet_hours": copy.deepcopy(quiet_hours),
+            "updated_at": self.now_factory().isoformat(),
+        }
+        try:
+            result = self.repository.replace_notification_preferences(
+                record, int(expected_revision)
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def dashboard(self, *, at: datetime | str | None = None):
+        timestamp = _time(at, "at") if at is not None else self.now_factory()
+        return self.repository.dashboard(timestamp)

+ 886 - 0
app/core/governance/work_center_repository.py

@@ -0,0 +1,886 @@
+"""PostgreSQL persistence for the thin unified governance work center."""
+
+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.events.outbox import enqueue_outbox
+
+
+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",
+            "published_by",
+        }:
+            result[key] = str(value)
+    return result
+
+
+class SqlAlchemyWorkCenterRepository:
+    """Store workflow evidence without mutating the authoritative source object."""
+
+    def __init__(self, session):
+        self.session = session
+
+    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(:uids)"
+            ),
+            {"uids": values},
+        )
+        return {str(row[0]) for row in rows}
+
+    def create_workflow(self, workflow, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_workflows (
+                    uid, code, name, status, current_version,
+                    active_version_uid, created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :name, :status,
+                    :current_version, NULL, CAST(:created_by AS uuid),
+                    :created_at, :updated_at
+                )
+                """
+            ),
+            workflow,
+        )
+        self._insert_workflow_version(version)
+        return copy.deepcopy(workflow)
+
+    def _insert_workflow_version(self, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_workflow_versions (
+                    uid, workflow_uid, version, status, definition,
+                    created_by, created_at, published_by, published_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:workflow_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 get_workflow(self, uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, code, name, status,
+                           current_version, active_version_uid::text AS active_version_uid,
+                           created_by::text AS created_by, created_at, updated_at
+                    FROM public.governance_workflows
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def workflow_version(self, workflow_uid, version):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, workflow_uid::text AS workflow_uid,
+                           version, status, definition,
+                           created_by::text AS created_by, created_at,
+                           published_by::text AS published_by, published_at
+                    FROM public.governance_workflow_versions
+                    WHERE workflow_uid = CAST(:workflow_uid AS uuid)
+                      AND version = :version
+                    """
+                ),
+                {"workflow_uid": workflow_uid, "version": int(version)},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def workflow_version_by_uid(self, uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, workflow_uid::text AS workflow_uid,
+                           version, status, definition,
+                           created_by::text AS created_by, created_at,
+                           published_by::text AS published_by, published_at
+                    FROM public.governance_workflow_versions
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def publish_workflow(self, workflow, version, expected_version):
+        self.session.execute(
+            text(
+                """
+                UPDATE public.governance_workflow_versions
+                SET status = 'superseded'
+                WHERE workflow_uid = CAST(:workflow_uid AS uuid)
+                  AND status = 'published'
+                """
+            ),
+            {"workflow_uid": workflow["uid"]},
+        )
+        updated = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_workflow_versions
+                SET status = 'published', published_by = CAST(:published_by AS uuid),
+                    published_at = :published_at
+                WHERE uid = CAST(:uid AS uuid) AND status = 'draft'
+                """
+            ),
+            version,
+        )
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_workflows
+                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
+                """
+            ),
+            {**workflow, "expected_version": expected_version},
+        )
+        if updated.rowcount != 1 or changed.rowcount != 1:
+            raise RuntimeError("workflow version conflict")
+        return self.get_workflow(workflow["uid"])
+
+    def revise_workflow(self, workflow, version, expected_version):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_workflows
+                SET status = :status, current_version = :current_version,
+                    updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**workflow, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("workflow version conflict")
+        self._insert_workflow_version(version)
+        return self.get_workflow(workflow["uid"])
+
+    def list_workflows(self):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, code, name, status, current_version,
+                       active_version_uid::text AS active_version_uid,
+                       created_by::text AS created_by, created_at, updated_at
+                FROM public.governance_workflows
+                ORDER BY updated_at DESC, uid DESC
+                """
+            )
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def create_task(self, task, participants):
+        self.session.execute(
+            text(
+                "SELECT pg_advisory_xact_lock(hashtext(:source_key))"
+            ),
+            {"source_key": f"{task['source_type']}:{task['source_uid']}"},
+        )
+        current = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid FROM public.governance_tasks
+                WHERE source_type = :source_type AND source_uid = :source_uid
+                  AND status NOT IN ('closed','cancelled')
+                """
+            ),
+            task,
+        ).scalar_one_or_none()
+        if current:
+            return {**self.get_task(current), "_created": False}
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_tasks (
+                    uid, task_code, workflow_uid, workflow_version, task_type,
+                    subject_type, subject_uid, source_type, source_uid, title,
+                    description, business_domain_uid, priority, status,
+                    assignee_uid, due_at, escalation_level, current_version,
+                    route_snapshot, context, source_state_unchanged,
+                    created_by, created_at, updated_by, updated_at, closed_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :task_code, CAST(:workflow_uid AS uuid),
+                    :workflow_version, :task_type, :subject_type, :subject_uid,
+                    :source_type, :source_uid, :title, :description,
+                    :business_domain_uid, :priority, :status,
+                    CAST(:assignee_uid AS uuid), :due_at, :escalation_level,
+                    :current_version, CAST(:route_snapshot AS jsonb),
+                    CAST(:context AS jsonb), :source_state_unchanged,
+                    CAST(:created_by AS uuid), :created_at,
+                    CAST(:updated_by AS uuid), :updated_at, :closed_at
+                )
+                """
+            ),
+            {
+                **task,
+                "route_snapshot": json.dumps(task["route_snapshot"], ensure_ascii=False),
+                "context": json.dumps(task["context"], ensure_ascii=False),
+            },
+        )
+        for participant in participants:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.governance_task_participants (
+                        uid, task_uid, user_uid, sequence, status,
+                        transferred_from_uid, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:task_uid AS uuid),
+                        CAST(:user_uid AS uuid), :sequence, :status,
+                        CAST(:transferred_from_uid AS uuid), :created_at
+                    )
+                    """
+                ),
+                participant,
+            )
+        self._event(task, 1, "created", task["created_by"], {}, {}, task["created_at"])
+        return {**copy.deepcopy(task), "_created": True}
+
+    def _task_select(self):
+        return """
+            SELECT uid::text AS uid, task_code, workflow_uid::text AS workflow_uid,
+                   workflow_version, task_type, subject_type, subject_uid,
+                   source_type, source_uid, title, description,
+                   business_domain_uid, priority, status,
+                   assignee_uid::text AS assignee_uid, due_at,
+                   escalation_level, current_version, route_snapshot, context,
+                   source_state_unchanged, created_by::text AS created_by,
+                   created_at, updated_by::text AS updated_by, updated_at, closed_at
+            FROM public.governance_tasks
+        """
+
+    def get_task(self, uid):
+        row = (
+            self.session.execute(
+                text(self._task_select() + " WHERE uid = CAST(:uid AS uuid)"),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def list_tasks(self, **filters):
+        clauses = []
+        params = {}
+        for key in ("status", "task_type", "subject_type", "assignee_uid"):
+            value = filters.get(key)
+            if not value:
+                continue
+            cast = "CAST(:assignee_uid AS uuid)" if key == "assignee_uid" else f":{key}"
+            clauses.append(f"{key} = {cast}")
+            params[key] = value
+        requester_uid = filters.get("requester_uid")
+        if requester_uid and not filters.get("can_manage"):
+            clauses.append(
+                "(assignee_uid = CAST(:requester_uid AS uuid) OR created_by = CAST(:requester_uid AS uuid) "
+                "OR EXISTS (SELECT 1 FROM public.governance_task_participants p "
+                "WHERE p.task_uid = governance_tasks.uid AND p.user_uid = CAST(:requester_uid AS uuid)))"
+            )
+            params["requester_uid"] = requester_uid
+        where = " WHERE " + " AND ".join(clauses) if clauses else ""
+        rows = self.session.execute(
+            text(self._task_select() + where + " ORDER BY due_at, priority DESC, created_at DESC"),
+            params,
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def update_task(self, task, expected_version, action, actor_uid, payload=None):
+        before = self.get_task(task["uid"])
+        if before is None:
+            raise LookupError("task was not found")
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_tasks SET
+                    status = :status, assignee_uid = CAST(:assignee_uid AS uuid),
+                    due_at = :due_at, escalation_level = :escalation_level,
+                    priority = :priority, current_version = current_version + 1,
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at,
+                    closed_at = :closed_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**task, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("task version conflict")
+        saved = self.get_task(task["uid"])
+        self._event(
+            saved,
+            saved["current_version"],
+            action,
+            actor_uid,
+            before,
+            payload or {},
+            saved["updated_at"],
+        )
+        enqueue_outbox(
+            self.session,
+            aggregate_type="governance_task",
+            aggregate_id=saved["uid"],
+            event_type="governance.task.evidence.recorded",
+            payload={
+                "task_uid": saved["uid"],
+                "source_type": saved["source_type"],
+                "source_uid": saved["source_uid"],
+                "action": action,
+                "status": saved["status"],
+                "source_state_unchanged": True,
+            },
+        )
+        return saved
+
+    def _event(self, task, version, action, actor_uid, before, payload, created_at):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_task_events (
+                    uid, task_uid, task_version, action, actor_uid,
+                    before_state, after_state, payload, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:task_uid AS uuid), :task_version,
+                    :action, CAST(:actor_uid AS uuid), CAST(:before_state AS jsonb),
+                    CAST(:after_state AS jsonb), CAST(:payload AS jsonb), :created_at
+                )
+                """
+            ),
+            {
+                "uid": new_governance_uid(),
+                "task_uid": task["uid"],
+                "task_version": version,
+                "action": action,
+                "actor_uid": actor_uid,
+                "before_state": json.dumps(before, ensure_ascii=False),
+                "after_state": json.dumps(task, ensure_ascii=False),
+                "payload": json.dumps(payload, ensure_ascii=False),
+                "created_at": created_at,
+            },
+        )
+
+    def participants_for_task(self, uid):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, task_uid::text AS task_uid,
+                       user_uid::text AS user_uid, sequence, status,
+                       transferred_from_uid::text AS transferred_from_uid, created_at
+                FROM public.governance_task_participants
+                WHERE task_uid = CAST(:uid AS uuid) ORDER BY sequence
+                """
+            ),
+            {"uid": uid},
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def replace_participant(self, task_uid, source_uid, target_uid, actor_uid):
+        target_exists = self.session.execute(
+            text(
+                """
+                SELECT 1 FROM public.governance_task_participants
+                WHERE task_uid = CAST(:task_uid AS uuid)
+                  AND user_uid = CAST(:target_uid AS uuid)
+                """
+            ),
+            {"task_uid": task_uid, "target_uid": target_uid},
+        ).scalar_one_or_none()
+        if target_exists:
+            raise ValueError("transfer target is already a participant")
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_task_participants
+                SET user_uid = CAST(:target_uid AS uuid),
+                    transferred_from_uid = CAST(:source_uid AS uuid)
+                WHERE task_uid = CAST(:task_uid AS uuid)
+                  AND user_uid = CAST(:source_uid AS uuid) AND status = 'pending'
+                """
+            ),
+            {"task_uid": task_uid, "source_uid": source_uid, "target_uid": target_uid},
+        )
+        if result.rowcount != 1:
+            raise LookupError("pending participant was not found")
+
+    def add_review(self, review):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_task_reviews (
+                    uid, task_uid, reviewer_uid, decision, reason, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:task_uid AS uuid),
+                    CAST(:reviewer_uid AS uuid), :decision, :reason, :created_at
+                )
+                """
+            ),
+            review,
+        )
+        self.session.execute(
+            text(
+                """
+                UPDATE public.governance_task_participants SET status = 'decided'
+                WHERE task_uid = CAST(:task_uid AS uuid)
+                  AND user_uid = CAST(:reviewer_uid AS uuid)
+                """
+            ),
+            review,
+        )
+        return copy.deepcopy(review)
+
+    def reviews_for_task(self, uid):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, task_uid::text AS task_uid,
+                       reviewer_uid::text AS reviewer_uid, decision, reason, created_at
+                FROM public.governance_task_reviews
+                WHERE task_uid = CAST(:uid AS uuid) ORDER BY created_at, uid
+                """
+            ),
+            {"uid": uid},
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def add_comment(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_task_comments (
+                    uid, task_uid, content, mentions, created_by, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:task_uid AS uuid), :content,
+                    CAST(:mentions AS jsonb), CAST(:created_by AS uuid), :created_at
+                )
+                """
+            ),
+            {**record, "mentions": json.dumps(record["mentions"])},
+        )
+        return copy.deepcopy(record)
+
+    def add_attachment(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_task_attachments (
+                    uid, task_uid, name, storage_ref, content_hash,
+                    created_by, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:task_uid AS uuid), :name,
+                    :storage_ref, :content_hash, CAST(:created_by AS uuid), :created_at
+                )
+                """
+            ),
+            record,
+        )
+        return copy.deepcopy(record)
+
+    def task_detail(self, uid):
+        task = self.get_task(uid)
+        if task is None:
+            return None
+        collections = {}
+        specs = {
+            "comments": ("governance_task_comments", "created_at, uid", "mentions"),
+            "attachments": ("governance_task_attachments", "created_at, uid", None),
+            "timeline": ("governance_task_events", "created_at, uid", None),
+        }
+        for name, (table, order, _json_key) in specs.items():
+            rows = self.session.execute(
+                text(f"SELECT * FROM public.{table} WHERE task_uid = CAST(:uid AS uuid) ORDER BY {order}"),
+                {"uid": uid},
+            ).mappings()
+            collections[name] = [_plain(row) for row in rows]
+        return {
+            **task,
+            "participants": self.participants_for_task(uid),
+            "reviews": self.reviews_for_task(uid),
+            **collections,
+        }
+
+    def overdue_tasks(self, at):
+        rows = self.session.execute(
+            text(
+                self._task_select()
+                + " WHERE status IN ('pending','in_progress','pending_review','reopened') AND due_at <= :at FOR UPDATE SKIP LOCKED"
+            ),
+            {"at": at},
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def create_notification(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_notifications (
+                    uid, event_key, event_type, recipient_uid, channel,
+                    subject, body, related_task_uid, status, attempts,
+                    max_attempts, next_attempt_at, last_error, delivered_at,
+                    read_at, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :event_key, :event_type,
+                    CAST(:recipient_uid AS uuid), :channel, :subject, :body,
+                    CAST(:related_task_uid AS uuid), :status, :attempts,
+                    :max_attempts, :next_attempt_at, :last_error,
+                    :delivered_at, :read_at, :created_at
+                ) ON CONFLICT (event_key) DO NOTHING
+                """
+            ),
+            record,
+        )
+        return copy.deepcopy(record)
+
+    def prepare_notification(self, record, task):
+        preference = self.get_notification_preferences(record["recipient_uid"])
+        channels = set(preference["enabled_channels"])
+        subscribed = set(preference["subscribed_events"])
+        if record["channel"] not in channels or (
+            subscribed and record["event_type"] not in subscribed
+        ):
+            return {**record, "status": "suppressed", "delivered_at": None}
+        template = self.session.execute(
+            text(
+                """
+                SELECT subject_template, body_template
+                FROM public.governance_notification_templates
+                WHERE code = :code AND channel = :channel AND status = 'active'
+                """
+            ),
+            {"code": record["event_type"], "channel": record["channel"]},
+        ).one_or_none()
+        if template is None:
+            return record
+        substitutions = {
+            "{{title}}": task["title"],
+            "{{description}}": task["description"],
+            "{{task_code}}": task["task_code"],
+        }
+        subject, body = template
+        for token, value in substitutions.items():
+            subject = subject.replace(token, value)
+            body = body.replace(token, value)
+        return {**record, "subject": subject, "body": body}
+
+    def list_notification_templates(self):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, code, channel, subject_template,
+                       body_template, status, current_version,
+                       created_by::text AS created_by,
+                       updated_by::text AS updated_by, created_at, updated_at
+                FROM public.governance_notification_templates
+                ORDER BY code, channel
+                """
+            )
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def create_notification_template(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_notification_templates (
+                    uid, code, channel, subject_template, body_template,
+                    status, current_version, created_by, updated_by,
+                    created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :channel, :subject_template,
+                    :body_template, :status, :current_version,
+                    CAST(:created_by AS uuid), CAST(:updated_by AS uuid),
+                    :created_at, :updated_at
+                )
+                """
+            ),
+            record,
+        )
+        return copy.deepcopy(record)
+
+    def revise_notification_template(self, record, expected_version):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_notification_templates SET
+                    subject_template = :subject_template,
+                    body_template = :body_template, 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 changed.rowcount != 1:
+            raise RuntimeError("notification template version conflict")
+        return next(
+            item
+            for item in self.list_notification_templates()
+            if item["uid"] == record["uid"]
+        )
+
+    def get_notification_preferences(self, user_uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT user_uid::text AS user_uid, enabled_channels,
+                           subscribed_events, quiet_hours, revision, updated_at
+                    FROM public.governance_notification_preferences
+                    WHERE user_uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": user_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if row:
+            return _plain(row)
+        return {
+            "user_uid": user_uid,
+            "enabled_channels": ["in_app", "email"],
+            "subscribed_events": [],
+            "quiet_hours": {},
+            "revision": 0,
+            "updated_at": None,
+        }
+
+    def replace_notification_preferences(self, record, expected_revision):
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
+            {"key": f"notification-preference:{record['user_uid']}"},
+        )
+        current = self.get_notification_preferences(record["user_uid"])
+        if int(current["revision"]) != expected_revision:
+            raise RuntimeError("notification preference revision conflict")
+        saved = {**record, "revision": expected_revision + 1}
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_notification_preferences (
+                    user_uid, enabled_channels, subscribed_events,
+                    quiet_hours, revision, updated_at
+                ) VALUES (
+                    CAST(:user_uid AS uuid), CAST(:enabled_channels AS jsonb),
+                    CAST(:subscribed_events AS jsonb), CAST(:quiet_hours AS jsonb),
+                    :revision, :updated_at
+                ) ON CONFLICT (user_uid) DO UPDATE SET
+                    enabled_channels = EXCLUDED.enabled_channels,
+                    subscribed_events = EXCLUDED.subscribed_events,
+                    quiet_hours = EXCLUDED.quiet_hours,
+                    revision = EXCLUDED.revision,
+                    updated_at = EXCLUDED.updated_at
+                """
+            ),
+            {
+                **saved,
+                "enabled_channels": json.dumps(saved["enabled_channels"]),
+                "subscribed_events": json.dumps(saved["subscribed_events"]),
+                "quiet_hours": json.dumps(saved["quiet_hours"]),
+            },
+        )
+        return saved
+
+    def list_notifications(self, recipient_uid, unread_only=False):
+        unread = " AND read_at IS NULL" if unread_only else ""
+        rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, event_key, event_type,
+                       recipient_uid::text AS recipient_uid, channel, subject,
+                       body, related_task_uid::text AS related_task_uid, status,
+                       attempts, max_attempts, next_attempt_at, last_error,
+                       delivered_at, read_at, created_at
+                FROM public.governance_notifications
+                WHERE recipient_uid = CAST(:uid AS uuid)
+                """ + unread + " ORDER BY created_at DESC, uid DESC"
+            ),
+            {"uid": recipient_uid},
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def claim_notifications(self, channel, at, limit):
+        rows = self.session.execute(
+            text(
+                """
+                WITH selected AS (
+                    SELECT uid FROM public.governance_notifications
+                    WHERE channel = :channel AND status = 'pending'
+                      AND next_attempt_at <= :at
+                    ORDER BY next_attempt_at, created_at
+                    FOR UPDATE SKIP LOCKED LIMIT :limit
+                ), updated AS (
+                    UPDATE public.governance_notifications n SET status = 'processing'
+                    FROM selected WHERE n.uid = selected.uid
+                    RETURNING n.*
+                )
+                SELECT n.uid::text AS uid, n.event_key, n.event_type,
+                       n.recipient_uid::text AS recipient_uid, n.channel,
+                       n.subject, n.body, n.related_task_uid::text AS related_task_uid,
+                       n.status, n.attempts, n.max_attempts, n.next_attempt_at,
+                       n.last_error, n.delivered_at, n.read_at, n.created_at,
+                       CASE WHEN position('@' in u.username) > 1
+                            THEN u.username ELSE NULL END AS recipient_email
+                FROM updated n JOIN public.users u ON u.id = n.recipient_uid
+                """
+            ),
+            {"channel": channel, "at": at, "limit": limit},
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def record_delivery(self, notification, result, at):
+        self.session.execute(
+            text(
+                """
+                UPDATE public.governance_notifications SET
+                    status = :status, attempts = :attempts,
+                    next_attempt_at = COALESCE(:available_at, next_attempt_at),
+                    last_error = :last_error,
+                    delivered_at = CASE WHEN :status = 'delivered' THEN :at ELSE delivered_at END
+                WHERE uid = CAST(:uid AS uuid) AND status = 'processing'
+                """
+            ),
+            {
+                "uid": notification["uid"],
+                "status": result.status,
+                "attempts": result.attempts,
+                "available_at": result.available_at,
+                "last_error": result.last_error,
+                "at": at,
+            },
+        )
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_notification_attempts (
+                    uid, notification_uid, attempt, status, safe_error, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:notification_uid AS uuid), :attempt,
+                    :status, :safe_error, :created_at
+                )
+                """
+            ),
+            {
+                "uid": new_governance_uid(),
+                "notification_uid": notification["uid"],
+                "attempt": result.attempts,
+                "status": result.status,
+                "safe_error": result.last_error,
+                "created_at": at,
+            },
+        )
+        return next(
+            item
+            for item in self.list_notifications(notification["recipient_uid"])
+            if item["uid"] == notification["uid"]
+        )
+
+    def mark_notification_read(self, uid, recipient_uid, at):
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_notifications SET read_at = COALESCE(read_at, :at)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND recipient_uid = CAST(:recipient_uid AS uuid)
+                """
+            ),
+            {"uid": uid, "recipient_uid": recipient_uid, "at": at},
+        )
+        if result.rowcount != 1:
+            raise LookupError("notification was not found")
+        return next(
+            item for item in self.list_notifications(recipient_uid) if item["uid"] == uid
+        )
+
+    def dashboard(self, at):
+        summary = self.session.execute(
+            text(
+                """
+                SELECT COUNT(*) AS task_count,
+                       COUNT(*) FILTER (WHERE status IN ('pending','in_progress','pending_review','reopened')) AS pending_count,
+                       COUNT(*) FILTER (WHERE status NOT IN ('closed','cancelled') AND due_at <= :at) AS overdue_count,
+                       COUNT(*) FILTER (WHERE status = 'closed') AS closed_count
+                FROM public.governance_tasks
+                """
+            ),
+            {"at": at},
+        ).mappings().one()
+        by_type = dict(
+            self.session.execute(
+                text(
+                    "SELECT subject_type, COUNT(*) FROM public.governance_tasks GROUP BY subject_type"
+                )
+            ).all()
+        )
+        trend_rows = self.session.execute(
+            text(
+                """
+                SELECT date_trunc('day', created_at)::date::text AS day,
+                       COUNT(*) AS created,
+                       COUNT(*) FILTER (WHERE status = 'closed') AS closed
+                FROM public.governance_tasks
+                WHERE created_at >= :at - INTERVAL '30 days'
+                GROUP BY 1 ORDER BY 1
+                """
+            ),
+            {"at": at},
+        ).all()
+        total = int(summary["task_count"] or 0)
+        closed = int(summary["closed_count"] or 0)
+        return {
+            **{key: int(value or 0) for key, value in summary.items()},
+            "closure_rate": closed / total if total else 0,
+            "by_type": {
+                kind: int(by_type.get(kind, 0))
+                for kind in ("quality_issue", "semantic_governance", "data_product", "agent")
+            },
+            "trend": [
+                {"day": row[0], "created": int(row[1]), "closed": int(row[2])}
+                for row in trend_rows
+            ],
+        }

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

@@ -58,6 +58,9 @@ SEMANTIC_GOVERNANCE_PUBLISH = "semantic-governance:publish"
 DATA_OBSERVABILITY_READ = "data-observability:read"
 DATA_OBSERVABILITY_OPERATE = "data-observability:operate"
 DATA_OBSERVABILITY_MANAGE = "data-observability:manage"
+WORK_CENTER_READ = "governance:work-center:read"
+WORK_CENTER_OPERATE = "governance:work-center:operate"
+WORK_CENTER_MANAGE = "governance:work-center:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -68,6 +71,7 @@ ROLE_PERMISSIONS = {
             DOMAIN_TEMPLATES_READ,
             ACTIVE_METADATA_READ,
             DATA_OBSERVABILITY_READ,
+            WORK_CENTER_READ,
         }
     ),
     "editor": frozenset(
@@ -97,6 +101,8 @@ ROLE_PERMISSIONS = {
             SEMANTIC_GOVERNANCE_EDIT,
             DATA_OBSERVABILITY_READ,
             DATA_OBSERVABILITY_OPERATE,
+            WORK_CENTER_READ,
+            WORK_CENTER_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -152,6 +158,9 @@ ROLE_PERMISSIONS = {
             DATA_OBSERVABILITY_READ,
             DATA_OBSERVABILITY_OPERATE,
             DATA_OBSERVABILITY_MANAGE,
+            WORK_CENTER_READ,
+            WORK_CENTER_OPERATE,
+            WORK_CENTER_MANAGE,
         }
     ),
 }
@@ -168,6 +177,15 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if method == "GET":
             return (RESPONSIBILITIES_READ,)
         return (RESPONSIBILITIES_MANAGE,)
+    if path.startswith("/api/system/work-center"):
+        if method == "GET":
+            return (WORK_CENTER_READ,)
+        if any(
+            segment in path
+            for segment in ("/workflows", "/templates", "/timeouts", "/deliver")
+        ):
+            return (WORK_CENTER_MANAGE,)
+        return (WORK_CENTER_OPERATE,)
     if path.startswith("/api/system/governance-audit"):
         if method == "GET":
             return (GOVERNANCE_AUDIT_READ,)

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

@@ -9,3 +9,4 @@ from app.api.system import responsibilities  # noqa: E402, F401
 from app.api.system import routes  # noqa: E402, F401
 from app.api.system import users  # noqa: E402, F401
 from app.api.system import workbench  # noqa: E402, F401
+from app.api.system import work_center  # noqa: E402, F401

+ 327 - 0
deployment/app/api/system/work_center.py

@@ -0,0 +1,327 @@
+"""Unified governance work-center API."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.system import bp
+from app.core.events.email_delivery import smtp_sender
+from app.core.governance.work_center import UnifiedWorkCenterService
+from app.core.governance.work_center_repository import SqlAlchemyWorkCenterRepository
+from app.core.system.permissions import (
+    WORK_CENTER_MANAGE,
+    WORK_CENTER_OPERATE,
+    WORK_CENTER_READ,
+    permissions_for_roles,
+    require_permissions,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    return UnifiedWorkCenterService(
+        SqlAlchemyWorkCenterRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _expected_version() -> int:
+    raw = str(request.headers.get("If-Match") or "").strip()
+    if raw.startswith("W/"):
+        raw = raw[2:].strip()
+    raw = raw.strip('"')
+    if not raw.isdigit():
+        raise ValueError("missing valid If-Match version")
+    return int(raw)
+
+
+def _etag(response, version):
+    response.headers["ETag"] = f'"{int(version)}"'
+    return response
+
+
+def _error(exc):
+    db.session.rollback()
+    if "If-Match" in str(exc):
+        status = 428
+    elif isinstance(exc, LookupError):
+        status = 404
+    elif isinstance(exc, PermissionError):
+        status = 403
+    elif isinstance(exc, RuntimeError):
+        status = 409
+    else:
+        status = 400
+    return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route("/work-center/workflows", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_workflows():
+    return jsonify(success(_service().list_workflows()))
+
+
+@bp.route("/work-center/workflows", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def create_work_center_workflow():
+    try:
+        result = _service().create_workflow(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "流程草稿已创建", code=201)), 1), 201
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/workflows/<workflow_uid>/revisions", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def revise_work_center_workflow(workflow_uid):
+    try:
+        result = _service().revise_workflow(
+            workflow_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "流程版本已创建")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/workflows/<workflow_uid>/publish", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def publish_work_center_workflow(workflow_uid):
+    try:
+        result = _service().publish_workflow(
+            workflow_uid,
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "流程版本已发布")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_tasks():
+    permissions = permissions_for_roles(g.current_user.get("roles", []))
+    filters = {
+        key: request.args.get(key)
+        for key in ("status", "task_type", "subject_type", "assignee_uid")
+        if request.args.get(key)
+    }
+    filters.update(
+        {
+            "requester_uid": g.current_user["id"],
+            "can_manage": WORK_CENTER_MANAGE in permissions,
+        }
+    )
+    return jsonify(success(_service().list_tasks(**filters)))
+
+
+@bp.route("/work-center/tasks", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def create_work_center_task():
+    try:
+        result = _service().create_task(
+            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, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks/<task_uid>", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def get_work_center_task(task_uid):
+    try:
+        permissions = permissions_for_roles(g.current_user.get("roles", []))
+        result = _service().task_detail(
+            task_uid,
+            requester_uid=g.current_user["id"],
+            can_manage=WORK_CENTER_MANAGE in permissions,
+        )
+        return _etag(jsonify(success(result)), result["current_version"])
+    except (ValueError, LookupError) as exc:
+        return _error(exc)
+
+
+def _task_action(task_uid, action):
+    service = _service()
+    body = request.get_json(silent=True) or {}
+    arguments = {
+        "expected_version": _expected_version(),
+        "actor_uid": g.current_user["id"],
+    }
+    if action == "review":
+        return service.review_task(task_uid, body, **arguments)
+    if action == "transfer":
+        return service.transfer_review(task_uid, body, **arguments)
+    if action == "close":
+        return service.close_task(task_uid, body, **arguments)
+    return service.reopen_task(task_uid, body, **arguments)
+
+
+@bp.route("/work-center/tasks/<task_uid>/<action>", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def operate_work_center_task(task_uid, action):
+    if action not in {"review", "transfer", "close", "reopen"}:
+        return jsonify(failed("unsupported task action", code=404)), 404
+    try:
+        result = _task_action(task_uid, action)
+        return _etag(jsonify(success(result, "任务状态已更新")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks/<task_uid>/comments", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def add_work_center_comment(task_uid):
+    try:
+        result = _service().add_comment(
+            task_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "评论已添加", code=201)), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks/<task_uid>/attachments", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def add_work_center_attachment(task_uid):
+    try:
+        result = _service().add_attachment(
+            task_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "附件证据已登记", code=201)), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/timeouts/process", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def process_work_center_timeouts():
+    try:
+        body = request.get_json(silent=True) or {}
+        return jsonify(
+            success(
+                _service().process_timeouts(
+                    at=body.get("at"), actor_uid=g.current_user["id"]
+                ),
+                "逾期任务已处理",
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/notifications", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_notifications():
+    return jsonify(
+        success(
+            _service().list_notifications(
+                g.current_user["id"],
+                unread_only=request.args.get("unread_only") == "true",
+            )
+        )
+    )
+
+
+@bp.route("/work-center/notifications/<notification_uid>/read", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def read_work_center_notification(notification_uid):
+    try:
+        return jsonify(
+            success(
+                _service().mark_notification_read(
+                    notification_uid, actor_uid=g.current_user["id"]
+                ),
+                "消息已读",
+            )
+        )
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/notifications/deliver", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def deliver_work_center_notifications():
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _service().deliver_notifications(
+            "email", smtp_sender, at=body.get("at"), limit=body.get("limit", 50)
+        )
+        return jsonify(success(result, "邮件投递批次已处理"))
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/templates", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_templates():
+    return jsonify(success(_service().list_notification_templates()))
+
+
+@bp.route("/work-center/templates", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def create_work_center_template():
+    try:
+        result = _service().create_notification_template(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "通知模板已创建", code=201)), 1), 201
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/templates/<template_uid>", methods=["PATCH"])
+@require_permissions(WORK_CENTER_MANAGE)
+def revise_work_center_template(template_uid):
+    try:
+        result = _service().revise_notification_template(
+            template_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "通知模板已更新")), result["current_version"])
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/preferences", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def get_work_center_preferences():
+    result = _service().get_notification_preferences(g.current_user["id"])
+    return _etag(jsonify(success(result)), result["revision"])
+
+
+@bp.route("/work-center/preferences", methods=["PUT"])
+@require_permissions(WORK_CENTER_OPERATE)
+def replace_work_center_preferences():
+    try:
+        result = _service().replace_notification_preferences(
+            request.get_json(silent=True) or {},
+            expected_revision=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "通知偏好已更新")), result["revision"])
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/dashboard", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def get_work_center_dashboard():
+    try:
+        return jsonify(success(_service().dashboard(at=request.args.get("at"))))
+    except ValueError as exc:
+        return _error(exc)

+ 30 - 0
deployment/app/core/events/email_delivery.py

@@ -0,0 +1,30 @@
+"""Small SMTP binding for work-center email delivery."""
+
+from __future__ import annotations
+
+import os
+import smtplib
+from email.message import EmailMessage
+
+
+def smtp_sender(notification: dict) -> None:
+    """Deliver one notification; configuration failures remain retryable evidence."""
+    host = os.getenv("DATAOPS_SMTP_HOST")
+    sender = os.getenv("DATAOPS_SMTP_FROM")
+    recipient = notification.get("recipient_email")
+    if not host or not sender or not recipient:
+        raise RuntimeError("SMTP binding is incomplete")
+    port = int(os.getenv("DATAOPS_SMTP_PORT", "587"))
+    message = EmailMessage()
+    message["From"] = sender
+    message["To"] = recipient
+    message["Subject"] = notification["subject"]
+    message.set_content(notification["body"])
+    with smtplib.SMTP(host, port, timeout=10) as client:
+        if os.getenv("DATAOPS_SMTP_STARTTLS", "true").lower() == "true":
+            client.starttls()
+        username = os.getenv("DATAOPS_SMTP_USERNAME")
+        password = os.getenv("DATAOPS_SMTP_PASSWORD")
+        if username and password:
+            client.login(username, password)
+        client.send_message(message)

+ 1070 - 0
deployment/app/core/governance/work_center.py

@@ -0,0 +1,1070 @@
+"""Thin unified work contract over existing governance source state machines."""
+
+from __future__ import annotations
+
+import copy
+import re
+import uuid
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+
+SUBJECT_TYPES = frozenset(
+    {"quality_issue", "semantic_governance", "data_product", "agent"}
+)
+TASK_TYPES = frozenset(
+    {
+        "approval",
+        "quality_issue",
+        "semantic_governance",
+        "data_product_approval",
+        "agent_approval",
+        "governance_work_order",
+        "release",
+        "high_risk",
+    }
+)
+APPROVAL_MODES = frozenset({"single", "any", "all", "dual_control"})
+PRIORITIES = frozenset({"low", "medium", "high", "critical"})
+DECISIONS = frozenset({"approve", "reject"})
+TIMEOUT_ACTIONS = frozenset({"escalate", "close"})
+NOTIFICATION_CHANNELS = frozenset({"in_app", "email"})
+TEMPLATE_STATUSES = frozenset({"active", "retired"})
+CONDITION_FIELDS = frozenset(
+    {
+        "business_domain_uid",
+        "risk_level",
+        "sensitivity_level",
+        "environment",
+        "amount",
+    }
+)
+CONDITION_OPERATORS = frozenset({"eq", "in", "gte", "lte"})
+ACTIVE_TASK_STATUSES = frozenset(
+    {"pending", "in_progress", "pending_review", "reopened"}
+)
+CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,119}$")
+HASH_PATTERN = re.compile(r"^[0-9a-f]{64}$")
+
+
+@dataclass(frozen=True)
+class DeliveryResult:
+    status: str
+    attempts: int
+    available_at: datetime | None = None
+    last_error: str | None = None
+
+
+def _closed(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise ValueError(f"{label} must be an object")
+    unknown = sorted(set(value) - allowed)
+    if unknown:
+        raise ValueError(f"{label} contains unsupported fields: {', '.join(unknown)}")
+    return copy.deepcopy(value)
+
+
+def _string(value: Any, label: str, maximum: int = 1000) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    result = value.strip()
+    if len(result) > maximum:
+        raise ValueError(f"{label} exceeds {maximum} characters")
+    return result
+
+
+def _optional_string(value: Any, label: str, maximum: int = 1000):
+    if value is None:
+        return None
+    return _string(value, label, maximum)
+
+
+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 _list(value: Any, label: str, *, minimum: int = 0) -> list[Any]:
+    if not isinstance(value, list) or len(value) < minimum:
+        raise ValueError(f"{label} must contain at least {minimum} items")
+    return copy.deepcopy(value)
+
+
+def _normalize_condition(value: Any) -> dict[str, Any]:
+    item = _closed(value, {"field", "operator", "value"}, "route condition")
+    field = _string(item.get("field"), "condition field", 50)
+    operator = _string(item.get("operator"), "condition operator", 10)
+    if field not in CONDITION_FIELDS or operator not in CONDITION_OPERATORS:
+        raise ValueError("unsupported route condition")
+    condition_value = item.get("value")
+    if operator == "in" and not isinstance(condition_value, list):
+        raise ValueError("in condition requires an array value")
+    if operator in {"gte", "lte"}:
+        try:
+            condition_value = float(condition_value)
+        except (TypeError, ValueError) as error:
+            raise ValueError("numeric route condition is invalid") from error
+    return {"field": field, "operator": operator, "value": condition_value}
+
+
+def _normalize_route(value: Any, *, priority: int = 9999) -> dict[str, Any]:
+    route = _closed(
+        value,
+        {
+            "priority",
+            "conditions",
+            "approval_mode",
+            "reviewer_uids",
+            "min_approvals",
+            "due_hours",
+            "timeout_action",
+            "notification_channels",
+        },
+        "workflow route",
+    )
+    try:
+        normalized_priority = int(route.get("priority", priority))
+        minimum = int(route.get("min_approvals", 1))
+        due_hours = int(route.get("due_hours", 24))
+    except (TypeError, ValueError) as error:
+        raise ValueError("workflow route numeric field is invalid") from error
+    if normalized_priority < 0 or due_hours < 1 or due_hours > 8760:
+        raise ValueError("workflow route priority or due time is invalid")
+    approval_mode = _string(route.get("approval_mode"), "approval_mode", 30)
+    if approval_mode not in APPROVAL_MODES:
+        raise ValueError("unsupported approval mode")
+    reviewers = sorted(
+        {
+            _uid(item, "reviewer uid")
+            for item in _list(route.get("reviewer_uids"), "reviewer_uids", minimum=1)
+        }
+    )
+    if minimum < 1 or minimum > len(reviewers):
+        raise ValueError("min approvals is outside reviewer range")
+    if approval_mode == "single" and (len(reviewers) != 1 or minimum != 1):
+        raise ValueError("single approval requires exactly one reviewer")
+    if approval_mode == "all" and minimum != len(reviewers):
+        raise ValueError("all approval requires every reviewer")
+    if approval_mode == "dual_control" and (len(reviewers) < 2 or minimum < 2):
+        raise ValueError("dual control requires two independent reviewers")
+    timeout_action = _string(
+        route.get("timeout_action", "escalate"), "timeout_action", 20
+    )
+    if timeout_action not in TIMEOUT_ACTIONS:
+        raise ValueError("unsupported timeout action")
+    channels = sorted(
+        {
+            _string(item, "notification channel", 20)
+            for item in route.get("notification_channels", ["in_app"])
+        }
+    )
+    if not channels or not set(channels) <= NOTIFICATION_CHANNELS:
+        raise ValueError("unsupported notification channel")
+    return {
+        "priority": normalized_priority,
+        "conditions": [
+            _normalize_condition(item) for item in route.get("conditions", [])
+        ],
+        "approval_mode": approval_mode,
+        "reviewer_uids": reviewers,
+        "min_approvals": minimum,
+        "due_hours": due_hours,
+        "timeout_action": timeout_action,
+        "notification_channels": channels,
+    }
+
+
+def _matches(route: dict[str, Any], context: dict[str, Any]) -> bool:
+    for condition in route["conditions"]:
+        actual = context.get(condition["field"])
+        expected = condition["value"]
+        operator = condition["operator"]
+        if operator == "eq" and actual != expected:
+            return False
+        if operator == "in" and actual not in expected:
+            return False
+        if operator in {"gte", "lte"}:
+            try:
+                numeric = float(actual)
+            except (TypeError, ValueError):
+                return False
+            if operator == "gte" and numeric < expected:
+                return False
+            if operator == "lte" and numeric > expected:
+                return False
+    return True
+
+
+def _redact_error(error: Exception) -> str:
+    message = re.sub(
+        r"(?i)(secret[-_ ]?token|api[-_ ]?key|authorization|bearer)(?:[=: ]+\S+)?",
+        "[redacted]",
+        str(error),
+    )
+    return message[:1000]
+
+
+class UnifiedWorkCenterService:
+    """Coordinate work evidence while source modules remain authoritative."""
+
+    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 create_workflow(self, payload: Any, *, actor_uid: str) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {"code", "name", "subject_types", "routes", "default_route"},
+            "workflow definition",
+        )
+        code = _string(body.get("code"), "code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("workflow code is invalid")
+        subject_types = sorted(
+            {
+                _string(item, "subject type", 40)
+                for item in _list(
+                    body.get("subject_types"), "subject_types", minimum=1
+                )
+            }
+        )
+        if not set(subject_types) <= SUBJECT_TYPES:
+            raise ValueError("unsupported workflow subject type")
+        routes = sorted(
+            [_normalize_route(item) for item in body.get("routes", [])],
+            key=lambda item: item["priority"],
+        )
+        default_route = _normalize_route(body.get("default_route"), priority=9999)
+        users = {
+            user
+            for route in [*routes, default_route]
+            for user in route["reviewer_uids"]
+        }
+        if self.repository.users_available(users) != users:
+            raise ValueError("workflow reviewer is unknown or disabled")
+        actor = _uid(actor_uid, "actor_uid")
+        now = self.now_factory().isoformat()
+        workflow_uid = self.uid_factory()
+        version_uid = self.uid_factory()
+        workflow = {
+            "uid": workflow_uid,
+            "code": code,
+            "name": _string(body.get("name"), "name", 300),
+            "status": "draft",
+            "current_version": 1,
+            "active_version_uid": None,
+            "created_by": actor,
+            "created_at": now,
+            "updated_at": now,
+        }
+        version = {
+            "uid": version_uid,
+            "workflow_uid": workflow_uid,
+            "version": 1,
+            "status": "draft",
+            "definition": {
+                "subject_types": subject_types,
+                "routes": routes,
+                "default_route": default_route,
+            },
+            "created_by": actor,
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        try:
+            self.repository.create_workflow(workflow, version)
+            self.commit()
+            return {**workflow, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def publish_workflow(
+        self, workflow_uid: str, *, expected_version: int, actor_uid: str
+    ) -> dict[str, Any]:
+        uid = _uid(workflow_uid, "workflow_uid")
+        workflow = self.repository.get_workflow(uid)
+        if workflow is None:
+            raise LookupError("workflow was not found")
+        if int(workflow["current_version"]) != int(expected_version):
+            raise RuntimeError("workflow version conflict")
+        version = self.repository.workflow_version(uid, int(expected_version))
+        if version is None or version["status"] != "draft":
+            raise RuntimeError("workflow 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 = {
+            **workflow,
+            "status": "published",
+            "active_version_uid": version["uid"],
+            "updated_at": now,
+        }
+        try:
+            self.repository.publish_workflow(
+                published, published_version, int(expected_version)
+            )
+            self.commit()
+            return {**published, "active_version": published_version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def revise_workflow(
+        self,
+        workflow_uid: str,
+        payload: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {"subject_types", "routes", "default_route"},
+            "workflow revision",
+        )
+        uid = _uid(workflow_uid, "workflow_uid")
+        workflow = self.repository.get_workflow(uid)
+        if workflow is None:
+            raise LookupError("workflow was not found")
+        if int(workflow["current_version"]) != int(expected_version):
+            raise RuntimeError("workflow version conflict")
+        subject_types = sorted(
+            {
+                _string(item, "subject type", 40)
+                for item in _list(body.get("subject_types"), "subject_types", minimum=1)
+            }
+        )
+        if not set(subject_types) <= SUBJECT_TYPES:
+            raise ValueError("unsupported workflow subject type")
+        routes = sorted(
+            [_normalize_route(item) for item in body.get("routes", [])],
+            key=lambda item: item["priority"],
+        )
+        default_route = _normalize_route(body.get("default_route"), priority=9999)
+        reviewers = {
+            reviewer
+            for route in [*routes, default_route]
+            for reviewer in route["reviewer_uids"]
+        }
+        if self.repository.users_available(reviewers) != reviewers:
+            raise ValueError("workflow reviewer is unknown or disabled")
+        now = self.now_factory().isoformat()
+        actor = _uid(actor_uid, "actor_uid")
+        next_version = int(expected_version) + 1
+        revised = {
+            **workflow,
+            "status": "published" if workflow.get("active_version_uid") else "draft",
+            "current_version": next_version,
+            "updated_at": now,
+        }
+        version = {
+            "uid": self.uid_factory(),
+            "workflow_uid": uid,
+            "version": next_version,
+            "status": "draft",
+            "definition": {
+                "subject_types": subject_types,
+                "routes": routes,
+                "default_route": default_route,
+            },
+            "created_by": actor,
+            "created_at": now,
+            "published_by": None,
+            "published_at": None,
+        }
+        try:
+            self.repository.revise_workflow(revised, version, int(expected_version))
+            self.commit()
+            return {**revised, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_workflows(self) -> list[dict[str, Any]]:
+        return self.repository.list_workflows()
+
+    def _published_definition(self, workflow_uid: str):
+        workflow = self.repository.get_workflow(_uid(workflow_uid, "workflow_uid"))
+        if workflow is None or workflow["status"] != "published":
+            raise LookupError("published workflow was not found")
+        if workflow.get("active_version_uid") and hasattr(
+            self.repository, "workflow_version_by_uid"
+        ):
+            version = self.repository.workflow_version_by_uid(
+                workflow["active_version_uid"]
+            )
+        else:
+            version = self.repository.workflow_version(
+                workflow["uid"], int(workflow["current_version"])
+            )
+        if version is None or version["status"] != "published":
+            raise RuntimeError("active workflow version is unavailable")
+        return workflow, version
+
+    def _select_route(self, definition, context):
+        return next(
+            (
+                copy.deepcopy(route)
+                for route in definition["routes"]
+                if _matches(route, context)
+            ),
+            copy.deepcopy(definition["default_route"]),
+        )
+
+    def create_task(self, payload: Any, *, actor_uid: str) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {
+                "workflow_uid",
+                "task_type",
+                "subject_type",
+                "subject_uid",
+                "source_type",
+                "source_uid",
+                "title",
+                "description",
+                "priority",
+                "business_domain_uid",
+                "context",
+            },
+            "unified task",
+        )
+        workflow, version = self._published_definition(body.get("workflow_uid"))
+        task_type = _string(body.get("task_type"), "task_type", 40)
+        subject_type = _string(body.get("subject_type"), "subject_type", 40)
+        if task_type not in TASK_TYPES or subject_type not in SUBJECT_TYPES:
+            raise ValueError("unsupported task or subject type")
+        if subject_type not in version["definition"]["subject_types"]:
+            raise ValueError("workflow does not support the subject type")
+        context = body.get("context") or {}
+        if not isinstance(context, dict):
+            raise ValueError("task context must be an object")
+        route = self._select_route(version["definition"], context)
+        if self.repository.users_available(route["reviewer_uids"]) != set(
+            route["reviewer_uids"]
+        ):
+            raise ValueError("task reviewer is unknown or disabled")
+        actor = _uid(actor_uid, "actor_uid")
+        now = self.now_factory()
+        task_uid = self.uid_factory()
+        task = {
+            "uid": task_uid,
+            "task_code": f"GWT-{now:%Y%m%d}-{task_uid[-8:].upper()}",
+            "workflow_uid": workflow["uid"],
+            "workflow_version": int(version["version"]),
+            "task_type": task_type,
+            "subject_type": subject_type,
+            "subject_uid": _string(body.get("subject_uid"), "subject_uid", 200),
+            "source_type": _string(body.get("source_type"), "source_type", 80),
+            "source_uid": _string(body.get("source_uid"), "source_uid", 200),
+            "title": _string(body.get("title"), "title", 300),
+            "description": _string(body.get("description"), "description", 2000),
+            "business_domain_uid": _optional_string(
+                body.get("business_domain_uid"), "business_domain_uid", 200
+            ),
+            "priority": _string(body.get("priority"), "priority", 20),
+            "status": "pending",
+            "assignee_uid": route["reviewer_uids"][0],
+            "due_at": (now + timedelta(hours=route["due_hours"])).isoformat(),
+            "escalation_level": 0,
+            "current_version": 1,
+            "route_snapshot": route,
+            "context": copy.deepcopy(context),
+            "source_state_unchanged": True,
+            "created_by": actor,
+            "created_at": now.isoformat(),
+            "updated_by": actor,
+            "updated_at": now.isoformat(),
+            "closed_at": None,
+        }
+        if task["priority"] not in PRIORITIES:
+            raise ValueError("unsupported task priority")
+        participants = [
+            {
+                "uid": self.uid_factory(),
+                "task_uid": task_uid,
+                "user_uid": reviewer,
+                "sequence": index + 1,
+                "status": "pending",
+                "transferred_from_uid": None,
+                "created_at": now.isoformat(),
+            }
+            for index, reviewer in enumerate(route["reviewer_uids"])
+        ]
+        try:
+            saved = self.repository.create_task(task, participants)
+            created = saved.pop("_created", True)
+            if created:
+                self._notify_task(saved, participants, event_type="task_created")
+            self.commit()
+            return saved
+        except Exception:
+            self.rollback()
+            raise
+
+    def _notify_task(self, task, participants, *, event_type):
+        now = self.now_factory()
+        for recipient in sorted({item["user_uid"] for item in participants}):
+            for channel in task["route_snapshot"]["notification_channels"]:
+                notification_uid = self.uid_factory()
+                record = {
+                        "uid": notification_uid,
+                        "event_key": f"{event_type}:{task['uid']}:{recipient}:{channel}",
+                        "event_type": event_type,
+                        "recipient_uid": recipient,
+                        "channel": channel,
+                        "subject": task["title"],
+                        "body": task["description"],
+                        "related_task_uid": task["uid"],
+                        "status": "delivered" if channel == "in_app" else "pending",
+                        "attempts": 0,
+                        "max_attempts": 5,
+                        "next_attempt_at": now.isoformat(),
+                        "last_error": None,
+                        "delivered_at": now.isoformat() if channel == "in_app" else None,
+                        "read_at": None,
+                        "created_at": now.isoformat(),
+                    }
+                if hasattr(self.repository, "prepare_notification"):
+                    record = self.repository.prepare_notification(record, task)
+                self.repository.create_notification(record)
+
+    def list_tasks(self, **filters) -> list[dict[str, Any]]:
+        return self.repository.list_tasks(**filters)
+
+    def task_detail(
+        self,
+        uid: str,
+        *,
+        requester_uid: str | None = None,
+        can_manage: bool = False,
+    ) -> dict[str, Any]:
+        result = self.repository.task_detail(_uid(uid, "task_uid"))
+        if result is None:
+            raise LookupError("task was not found")
+        if requester_uid and not can_manage:
+            actor = _uid(requester_uid, "requester_uid")
+            visible = {result["created_by"], result["assignee_uid"]} | {
+                item["user_uid"] for item in result.get("participants", [])
+            }
+            if actor not in visible:
+                raise LookupError("task was not found")
+        return result
+
+    def _require_task_collaborator(self, task_uid: str, actor_uid: str):
+        task = self.repository.get_task(task_uid)
+        if task is None:
+            raise LookupError("task was not found")
+        participant_uids = {
+            item["user_uid"] for item in self.repository.participants_for_task(task_uid)
+        }
+        if actor_uid not in {
+            task["created_by"],
+            task["assignee_uid"],
+            *participant_uids,
+        }:
+            raise PermissionError("actor is not a task collaborator")
+        return task
+
+    def review_task(
+        self,
+        task_uid: str,
+        payload: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed(payload, {"decision", "reason"}, "task review")
+        uid = _uid(task_uid, "task_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        task = self.repository.get_task(uid)
+        if task is None:
+            raise LookupError("task was not found")
+        if task["status"] not in ACTIVE_TASK_STATUSES:
+            raise RuntimeError("task is not reviewable")
+        participants = self.repository.participants_for_task(uid)
+        if not any(
+            item["user_uid"] == actor and item["status"] == "pending"
+            for item in participants
+        ):
+            raise PermissionError("reviewer is not a pending participant")
+        mode = task["route_snapshot"]["approval_mode"]
+        if mode == "dual_control" and actor == task["created_by"]:
+            raise PermissionError("dual control creator cannot review")
+        decision = _string(body.get("decision"), "decision", 20)
+        if decision not in DECISIONS:
+            raise ValueError("unsupported review decision")
+        now = self.now_factory().isoformat()
+        self.repository.add_review(
+            {
+                "uid": self.uid_factory(),
+                "task_uid": uid,
+                "reviewer_uid": actor,
+                "decision": decision,
+                "reason": _string(body.get("reason"), "reason", 1000),
+                "created_at": now,
+            }
+        )
+        reviews = self.repository.reviews_for_task(uid)
+        approvals = {item["reviewer_uid"] for item in reviews if item["decision"] == "approve"}
+        if any(item["decision"] == "reject" for item in reviews):
+            status = "rejected"
+        elif mode == "all":
+            status = "approved" if len(approvals) == len(participants) else "pending"
+        else:
+            status = (
+                "approved"
+                if len(approvals) >= int(task["route_snapshot"]["min_approvals"])
+                else "pending"
+            )
+        task.update({"status": status, "updated_by": actor, "updated_at": now})
+        try:
+            result = self.repository.update_task(
+                task,
+                int(expected_version),
+                "reviewed",
+                actor,
+                {"decision": decision, "resulting_status": status},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def transfer_review(
+        self,
+        task_uid: str,
+        payload: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed(
+            payload,
+            {"source_user_uid", "target_user_uid", "reason"},
+            "review transfer",
+        )
+        uid = _uid(task_uid, "task_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        source = _uid(body.get("source_user_uid"), "source_user_uid")
+        target = _uid(body.get("target_user_uid"), "target_user_uid")
+        if actor != source:
+            raise PermissionError("only the pending reviewer can transfer")
+        if source == target or self.repository.users_available([target]) != {target}:
+            raise ValueError("transfer target is invalid or unavailable")
+        task = self.repository.get_task(uid)
+        if task is None:
+            raise LookupError("task was not found")
+        now = self.now_factory().isoformat()
+        try:
+            self.repository.replace_participant(uid, source, target, actor)
+            task.update(
+                {"assignee_uid": target, "updated_by": actor, "updated_at": now}
+            )
+            result = self.repository.update_task(
+                task,
+                int(expected_version),
+                "review_transferred",
+                actor,
+                {"source_user_uid": source, "target_user_uid": target, "reason": _string(body.get("reason"), "reason", 500)},
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def add_comment(self, task_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(payload, {"content", "mentions"}, "task comment")
+        uid = _uid(task_uid, "task_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        self._require_task_collaborator(uid, actor)
+        mentions = sorted(
+            {_uid(item, "mention uid") for item in body.get("mentions", [])}
+        )
+        if self.repository.users_available(mentions) != set(mentions):
+            raise ValueError("mentioned user is unavailable")
+        record = {
+            "uid": self.uid_factory(),
+            "task_uid": uid,
+            "content": _string(body.get("content"), "content", 4000),
+            "mentions": mentions,
+            "created_by": actor,
+            "created_at": self.now_factory().isoformat(),
+        }
+        try:
+            result = self.repository.add_comment(record)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def add_attachment(self, task_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"name", "storage_ref", "content_hash"},
+            "task attachment",
+        )
+        uid = _uid(task_uid, "task_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        self._require_task_collaborator(uid, actor)
+        content_hash = _string(body.get("content_hash"), "content_hash", 64).lower()
+        if not HASH_PATTERN.fullmatch(content_hash):
+            raise ValueError("attachment content hash is invalid")
+        record = {
+            "uid": self.uid_factory(),
+            "task_uid": uid,
+            "name": _string(body.get("name"), "name", 300),
+            "storage_ref": _string(body.get("storage_ref"), "storage_ref", 1000),
+            "content_hash": content_hash,
+            "created_by": actor,
+            "created_at": self.now_factory().isoformat(),
+        }
+        try:
+            result = self.repository.add_attachment(record)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def _transition_task(
+        self,
+        task_uid,
+        payload,
+        *,
+        expected_version,
+        actor_uid,
+        action,
+    ):
+        uid = _uid(task_uid, "task_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        task = self.repository.get_task(uid)
+        if task is None:
+            raise LookupError("task was not found")
+        participant_uids = {
+            item["user_uid"] for item in self.repository.participants_for_task(uid)
+        }
+        if actor not in {task["created_by"], task["assignee_uid"], *participant_uids}:
+            raise PermissionError("actor is not a task collaborator")
+        now = self.now_factory().isoformat()
+        if action == "closed":
+            body = _closed(payload, {"resolution", "evidence_refs"}, "task closure")
+            evidence = _list(body.get("evidence_refs"), "evidence_refs", minimum=1)
+            task.update({"status": "closed", "closed_at": now})
+            audit_payload = {
+                "resolution": _string(body.get("resolution"), "resolution", 2000),
+                "evidence_refs": [_string(item, "evidence ref", 1000) for item in evidence],
+            }
+        else:
+            if task["status"] != "closed":
+                raise RuntimeError("only a closed task can be reopened")
+            body = _closed(payload, {"reason"}, "task reopening")
+            task.update({"status": "reopened", "closed_at": None})
+            audit_payload = {"reason": _string(body.get("reason"), "reason", 1000)}
+        task.update({"updated_by": actor, "updated_at": now})
+        try:
+            result = self.repository.update_task(
+                task,
+                int(expected_version),
+                action,
+                actor,
+                audit_payload,
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def close_task(self, task_uid, payload, *, expected_version, actor_uid):
+        return self._transition_task(
+            task_uid,
+            payload,
+            expected_version=expected_version,
+            actor_uid=actor_uid,
+            action="closed",
+        )
+
+    def reopen_task(self, task_uid, payload, *, expected_version, actor_uid):
+        return self._transition_task(
+            task_uid,
+            payload,
+            expected_version=expected_version,
+            actor_uid=actor_uid,
+            action="reopened",
+        )
+
+    def process_timeouts(
+        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()
+        actor = _uid(actor_uid, "actor_uid")
+        results = []
+        try:
+            for task in self.repository.overdue_tasks(timestamp):
+                action = task["route_snapshot"]["timeout_action"]
+                if action == "close":
+                    task.update({"status": "closed", "closed_at": timestamp.isoformat()})
+                    event = "timeout_closed"
+                else:
+                    task["escalation_level"] = int(task["escalation_level"]) + 1
+                    task["priority"] = "critical"
+                    task["due_at"] = (
+                        timestamp
+                        + timedelta(hours=int(task["route_snapshot"]["due_hours"]))
+                    ).isoformat()
+                    event = "timeout_escalated"
+                task.update(
+                    {
+                        "updated_by": actor,
+                        "updated_at": timestamp.isoformat(),
+                    }
+                )
+                results.append(
+                    self.repository.update_task(
+                        task,
+                        int(task["current_version"]),
+                        event,
+                        actor,
+                        {"processed_at": timestamp.isoformat()},
+                    )
+                )
+            self.commit()
+            return results
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_notifications(self, owner_uid: str, *, unread_only: bool = False):
+        return self.repository.list_notifications(
+            _uid(owner_uid, "owner_uid"), unread_only=bool(unread_only)
+        )
+
+    def deliver_notifications(
+        self,
+        channel: str,
+        adapter: Callable[[dict[str, Any]], None],
+        *,
+        at: datetime | str | None = None,
+        limit: int = 50,
+    ) -> list[dict[str, Any]]:
+        normalized_channel = _string(channel, "channel", 20)
+        if normalized_channel != "email":
+            raise ValueError("only email requires external delivery")
+        timestamp = _time(at, "at") if at is not None else self.now_factory()
+        records = self.repository.claim_notifications(
+            normalized_channel, timestamp, max(1, min(int(limit), 200))
+        )
+        results = []
+        try:
+            for notification in records:
+                attempts = int(notification["attempts"]) + 1
+                try:
+                    adapter(notification)
+                    result = DeliveryResult(status="delivered", attempts=attempts)
+                except Exception as error:
+                    if attempts >= int(notification["max_attempts"]):
+                        result = DeliveryResult(
+                            status="dead_letter",
+                            attempts=attempts,
+                            last_error=_redact_error(error),
+                        )
+                    else:
+                        result = DeliveryResult(
+                            status="pending",
+                            attempts=attempts,
+                            available_at=timestamp
+                            + timedelta(seconds=min(300, 2**attempts)),
+                            last_error=_redact_error(error),
+                        )
+                results.append(
+                    self.repository.record_delivery(notification, result, timestamp)
+                )
+            self.commit()
+            return results
+        except Exception:
+            self.rollback()
+            raise
+
+    def mark_notification_read(self, uid: str, *, actor_uid: str):
+        try:
+            result = self.repository.mark_notification_read(
+                _uid(uid, "notification_uid"),
+                _uid(actor_uid, "actor_uid"),
+                self.now_factory(),
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_notification_templates(self):
+        return self.repository.list_notification_templates()
+
+    def create_notification_template(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"code", "channel", "subject_template", "body_template"},
+            "notification template",
+        )
+        code = _string(body.get("code"), "code", 120).lower()
+        if not re.fullmatch(r"[a-z][a-z0-9_.-]{2,119}", code):
+            raise ValueError("notification template code is invalid")
+        channel = _string(body.get("channel"), "channel", 20)
+        if channel not in NOTIFICATION_CHANNELS:
+            raise ValueError("unsupported notification channel")
+        now = self.now_factory().isoformat()
+        actor = _uid(actor_uid, "actor_uid")
+        record = {
+            "uid": self.uid_factory(),
+            "code": code,
+            "channel": channel,
+            "subject_template": _string(
+                body.get("subject_template"), "subject_template", 500
+            ),
+            "body_template": _string(
+                body.get("body_template"), "body_template", 4000
+            ),
+            "status": "active",
+            "current_version": 1,
+            "created_by": actor,
+            "updated_by": actor,
+            "created_at": now,
+            "updated_at": now,
+        }
+        try:
+            result = self.repository.create_notification_template(record)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def revise_notification_template(
+        self,
+        uid: str,
+        payload: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        body = _closed(
+            payload,
+            {"subject_template", "body_template", "status"},
+            "notification template revision",
+        )
+        status = _string(body.get("status"), "status", 20)
+        if status not in TEMPLATE_STATUSES:
+            raise ValueError("unsupported notification template status")
+        record = {
+            "uid": _uid(uid, "template_uid"),
+            "subject_template": _string(
+                body.get("subject_template"), "subject_template", 500
+            ),
+            "body_template": _string(
+                body.get("body_template"), "body_template", 4000
+            ),
+            "status": status,
+            "updated_by": _uid(actor_uid, "actor_uid"),
+            "updated_at": self.now_factory().isoformat(),
+        }
+        try:
+            result = self.repository.revise_notification_template(
+                record, int(expected_version)
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def get_notification_preferences(self, user_uid: str):
+        return self.repository.get_notification_preferences(_uid(user_uid, "user_uid"))
+
+    def replace_notification_preferences(
+        self,
+        payload: Any,
+        *,
+        expected_revision: int,
+        actor_uid: str,
+    ):
+        body = _closed(
+            payload,
+            {"enabled_channels", "subscribed_events", "quiet_hours"},
+            "notification preferences",
+        )
+        channels = sorted(
+            {_string(item, "channel", 20) for item in body.get("enabled_channels", [])}
+        )
+        if not set(channels) <= NOTIFICATION_CHANNELS:
+            raise ValueError("unsupported notification channel")
+        events = sorted(
+            {
+                _string(item, "subscribed event", 80)
+                for item in body.get("subscribed_events", [])
+            }
+        )
+        quiet_hours = body.get("quiet_hours") or {}
+        if not isinstance(quiet_hours, dict):
+            raise ValueError("quiet_hours must be an object")
+        record = {
+            "user_uid": _uid(actor_uid, "actor_uid"),
+            "enabled_channels": channels,
+            "subscribed_events": events,
+            "quiet_hours": copy.deepcopy(quiet_hours),
+            "updated_at": self.now_factory().isoformat(),
+        }
+        try:
+            result = self.repository.replace_notification_preferences(
+                record, int(expected_revision)
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def dashboard(self, *, at: datetime | str | None = None):
+        timestamp = _time(at, "at") if at is not None else self.now_factory()
+        return self.repository.dashboard(timestamp)

+ 886 - 0
deployment/app/core/governance/work_center_repository.py

@@ -0,0 +1,886 @@
+"""PostgreSQL persistence for the thin unified governance work center."""
+
+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.events.outbox import enqueue_outbox
+
+
+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",
+            "published_by",
+        }:
+            result[key] = str(value)
+    return result
+
+
+class SqlAlchemyWorkCenterRepository:
+    """Store workflow evidence without mutating the authoritative source object."""
+
+    def __init__(self, session):
+        self.session = session
+
+    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(:uids)"
+            ),
+            {"uids": values},
+        )
+        return {str(row[0]) for row in rows}
+
+    def create_workflow(self, workflow, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_workflows (
+                    uid, code, name, status, current_version,
+                    active_version_uid, created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :name, :status,
+                    :current_version, NULL, CAST(:created_by AS uuid),
+                    :created_at, :updated_at
+                )
+                """
+            ),
+            workflow,
+        )
+        self._insert_workflow_version(version)
+        return copy.deepcopy(workflow)
+
+    def _insert_workflow_version(self, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_workflow_versions (
+                    uid, workflow_uid, version, status, definition,
+                    created_by, created_at, published_by, published_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:workflow_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 get_workflow(self, uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, code, name, status,
+                           current_version, active_version_uid::text AS active_version_uid,
+                           created_by::text AS created_by, created_at, updated_at
+                    FROM public.governance_workflows
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def workflow_version(self, workflow_uid, version):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, workflow_uid::text AS workflow_uid,
+                           version, status, definition,
+                           created_by::text AS created_by, created_at,
+                           published_by::text AS published_by, published_at
+                    FROM public.governance_workflow_versions
+                    WHERE workflow_uid = CAST(:workflow_uid AS uuid)
+                      AND version = :version
+                    """
+                ),
+                {"workflow_uid": workflow_uid, "version": int(version)},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def workflow_version_by_uid(self, uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid::text AS uid, workflow_uid::text AS workflow_uid,
+                           version, status, definition,
+                           created_by::text AS created_by, created_at,
+                           published_by::text AS published_by, published_at
+                    FROM public.governance_workflow_versions
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def publish_workflow(self, workflow, version, expected_version):
+        self.session.execute(
+            text(
+                """
+                UPDATE public.governance_workflow_versions
+                SET status = 'superseded'
+                WHERE workflow_uid = CAST(:workflow_uid AS uuid)
+                  AND status = 'published'
+                """
+            ),
+            {"workflow_uid": workflow["uid"]},
+        )
+        updated = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_workflow_versions
+                SET status = 'published', published_by = CAST(:published_by AS uuid),
+                    published_at = :published_at
+                WHERE uid = CAST(:uid AS uuid) AND status = 'draft'
+                """
+            ),
+            version,
+        )
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_workflows
+                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
+                """
+            ),
+            {**workflow, "expected_version": expected_version},
+        )
+        if updated.rowcount != 1 or changed.rowcount != 1:
+            raise RuntimeError("workflow version conflict")
+        return self.get_workflow(workflow["uid"])
+
+    def revise_workflow(self, workflow, version, expected_version):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_workflows
+                SET status = :status, current_version = :current_version,
+                    updated_at = :updated_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**workflow, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("workflow version conflict")
+        self._insert_workflow_version(version)
+        return self.get_workflow(workflow["uid"])
+
+    def list_workflows(self):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, code, name, status, current_version,
+                       active_version_uid::text AS active_version_uid,
+                       created_by::text AS created_by, created_at, updated_at
+                FROM public.governance_workflows
+                ORDER BY updated_at DESC, uid DESC
+                """
+            )
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def create_task(self, task, participants):
+        self.session.execute(
+            text(
+                "SELECT pg_advisory_xact_lock(hashtext(:source_key))"
+            ),
+            {"source_key": f"{task['source_type']}:{task['source_uid']}"},
+        )
+        current = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid FROM public.governance_tasks
+                WHERE source_type = :source_type AND source_uid = :source_uid
+                  AND status NOT IN ('closed','cancelled')
+                """
+            ),
+            task,
+        ).scalar_one_or_none()
+        if current:
+            return {**self.get_task(current), "_created": False}
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_tasks (
+                    uid, task_code, workflow_uid, workflow_version, task_type,
+                    subject_type, subject_uid, source_type, source_uid, title,
+                    description, business_domain_uid, priority, status,
+                    assignee_uid, due_at, escalation_level, current_version,
+                    route_snapshot, context, source_state_unchanged,
+                    created_by, created_at, updated_by, updated_at, closed_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :task_code, CAST(:workflow_uid AS uuid),
+                    :workflow_version, :task_type, :subject_type, :subject_uid,
+                    :source_type, :source_uid, :title, :description,
+                    :business_domain_uid, :priority, :status,
+                    CAST(:assignee_uid AS uuid), :due_at, :escalation_level,
+                    :current_version, CAST(:route_snapshot AS jsonb),
+                    CAST(:context AS jsonb), :source_state_unchanged,
+                    CAST(:created_by AS uuid), :created_at,
+                    CAST(:updated_by AS uuid), :updated_at, :closed_at
+                )
+                """
+            ),
+            {
+                **task,
+                "route_snapshot": json.dumps(task["route_snapshot"], ensure_ascii=False),
+                "context": json.dumps(task["context"], ensure_ascii=False),
+            },
+        )
+        for participant in participants:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.governance_task_participants (
+                        uid, task_uid, user_uid, sequence, status,
+                        transferred_from_uid, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:task_uid AS uuid),
+                        CAST(:user_uid AS uuid), :sequence, :status,
+                        CAST(:transferred_from_uid AS uuid), :created_at
+                    )
+                    """
+                ),
+                participant,
+            )
+        self._event(task, 1, "created", task["created_by"], {}, {}, task["created_at"])
+        return {**copy.deepcopy(task), "_created": True}
+
+    def _task_select(self):
+        return """
+            SELECT uid::text AS uid, task_code, workflow_uid::text AS workflow_uid,
+                   workflow_version, task_type, subject_type, subject_uid,
+                   source_type, source_uid, title, description,
+                   business_domain_uid, priority, status,
+                   assignee_uid::text AS assignee_uid, due_at,
+                   escalation_level, current_version, route_snapshot, context,
+                   source_state_unchanged, created_by::text AS created_by,
+                   created_at, updated_by::text AS updated_by, updated_at, closed_at
+            FROM public.governance_tasks
+        """
+
+    def get_task(self, uid):
+        row = (
+            self.session.execute(
+                text(self._task_select() + " WHERE uid = CAST(:uid AS uuid)"),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _plain(row) if row else None
+
+    def list_tasks(self, **filters):
+        clauses = []
+        params = {}
+        for key in ("status", "task_type", "subject_type", "assignee_uid"):
+            value = filters.get(key)
+            if not value:
+                continue
+            cast = "CAST(:assignee_uid AS uuid)" if key == "assignee_uid" else f":{key}"
+            clauses.append(f"{key} = {cast}")
+            params[key] = value
+        requester_uid = filters.get("requester_uid")
+        if requester_uid and not filters.get("can_manage"):
+            clauses.append(
+                "(assignee_uid = CAST(:requester_uid AS uuid) OR created_by = CAST(:requester_uid AS uuid) "
+                "OR EXISTS (SELECT 1 FROM public.governance_task_participants p "
+                "WHERE p.task_uid = governance_tasks.uid AND p.user_uid = CAST(:requester_uid AS uuid)))"
+            )
+            params["requester_uid"] = requester_uid
+        where = " WHERE " + " AND ".join(clauses) if clauses else ""
+        rows = self.session.execute(
+            text(self._task_select() + where + " ORDER BY due_at, priority DESC, created_at DESC"),
+            params,
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def update_task(self, task, expected_version, action, actor_uid, payload=None):
+        before = self.get_task(task["uid"])
+        if before is None:
+            raise LookupError("task was not found")
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_tasks SET
+                    status = :status, assignee_uid = CAST(:assignee_uid AS uuid),
+                    due_at = :due_at, escalation_level = :escalation_level,
+                    priority = :priority, current_version = current_version + 1,
+                    updated_by = CAST(:updated_by AS uuid), updated_at = :updated_at,
+                    closed_at = :closed_at
+                WHERE uid = CAST(:uid AS uuid) AND current_version = :expected_version
+                """
+            ),
+            {**task, "expected_version": expected_version},
+        )
+        if changed.rowcount != 1:
+            raise RuntimeError("task version conflict")
+        saved = self.get_task(task["uid"])
+        self._event(
+            saved,
+            saved["current_version"],
+            action,
+            actor_uid,
+            before,
+            payload or {},
+            saved["updated_at"],
+        )
+        enqueue_outbox(
+            self.session,
+            aggregate_type="governance_task",
+            aggregate_id=saved["uid"],
+            event_type="governance.task.evidence.recorded",
+            payload={
+                "task_uid": saved["uid"],
+                "source_type": saved["source_type"],
+                "source_uid": saved["source_uid"],
+                "action": action,
+                "status": saved["status"],
+                "source_state_unchanged": True,
+            },
+        )
+        return saved
+
+    def _event(self, task, version, action, actor_uid, before, payload, created_at):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_task_events (
+                    uid, task_uid, task_version, action, actor_uid,
+                    before_state, after_state, payload, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:task_uid AS uuid), :task_version,
+                    :action, CAST(:actor_uid AS uuid), CAST(:before_state AS jsonb),
+                    CAST(:after_state AS jsonb), CAST(:payload AS jsonb), :created_at
+                )
+                """
+            ),
+            {
+                "uid": new_governance_uid(),
+                "task_uid": task["uid"],
+                "task_version": version,
+                "action": action,
+                "actor_uid": actor_uid,
+                "before_state": json.dumps(before, ensure_ascii=False),
+                "after_state": json.dumps(task, ensure_ascii=False),
+                "payload": json.dumps(payload, ensure_ascii=False),
+                "created_at": created_at,
+            },
+        )
+
+    def participants_for_task(self, uid):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, task_uid::text AS task_uid,
+                       user_uid::text AS user_uid, sequence, status,
+                       transferred_from_uid::text AS transferred_from_uid, created_at
+                FROM public.governance_task_participants
+                WHERE task_uid = CAST(:uid AS uuid) ORDER BY sequence
+                """
+            ),
+            {"uid": uid},
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def replace_participant(self, task_uid, source_uid, target_uid, actor_uid):
+        target_exists = self.session.execute(
+            text(
+                """
+                SELECT 1 FROM public.governance_task_participants
+                WHERE task_uid = CAST(:task_uid AS uuid)
+                  AND user_uid = CAST(:target_uid AS uuid)
+                """
+            ),
+            {"task_uid": task_uid, "target_uid": target_uid},
+        ).scalar_one_or_none()
+        if target_exists:
+            raise ValueError("transfer target is already a participant")
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_task_participants
+                SET user_uid = CAST(:target_uid AS uuid),
+                    transferred_from_uid = CAST(:source_uid AS uuid)
+                WHERE task_uid = CAST(:task_uid AS uuid)
+                  AND user_uid = CAST(:source_uid AS uuid) AND status = 'pending'
+                """
+            ),
+            {"task_uid": task_uid, "source_uid": source_uid, "target_uid": target_uid},
+        )
+        if result.rowcount != 1:
+            raise LookupError("pending participant was not found")
+
+    def add_review(self, review):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_task_reviews (
+                    uid, task_uid, reviewer_uid, decision, reason, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:task_uid AS uuid),
+                    CAST(:reviewer_uid AS uuid), :decision, :reason, :created_at
+                )
+                """
+            ),
+            review,
+        )
+        self.session.execute(
+            text(
+                """
+                UPDATE public.governance_task_participants SET status = 'decided'
+                WHERE task_uid = CAST(:task_uid AS uuid)
+                  AND user_uid = CAST(:reviewer_uid AS uuid)
+                """
+            ),
+            review,
+        )
+        return copy.deepcopy(review)
+
+    def reviews_for_task(self, uid):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, task_uid::text AS task_uid,
+                       reviewer_uid::text AS reviewer_uid, decision, reason, created_at
+                FROM public.governance_task_reviews
+                WHERE task_uid = CAST(:uid AS uuid) ORDER BY created_at, uid
+                """
+            ),
+            {"uid": uid},
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def add_comment(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_task_comments (
+                    uid, task_uid, content, mentions, created_by, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:task_uid AS uuid), :content,
+                    CAST(:mentions AS jsonb), CAST(:created_by AS uuid), :created_at
+                )
+                """
+            ),
+            {**record, "mentions": json.dumps(record["mentions"])},
+        )
+        return copy.deepcopy(record)
+
+    def add_attachment(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_task_attachments (
+                    uid, task_uid, name, storage_ref, content_hash,
+                    created_by, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:task_uid AS uuid), :name,
+                    :storage_ref, :content_hash, CAST(:created_by AS uuid), :created_at
+                )
+                """
+            ),
+            record,
+        )
+        return copy.deepcopy(record)
+
+    def task_detail(self, uid):
+        task = self.get_task(uid)
+        if task is None:
+            return None
+        collections = {}
+        specs = {
+            "comments": ("governance_task_comments", "created_at, uid", "mentions"),
+            "attachments": ("governance_task_attachments", "created_at, uid", None),
+            "timeline": ("governance_task_events", "created_at, uid", None),
+        }
+        for name, (table, order, _json_key) in specs.items():
+            rows = self.session.execute(
+                text(f"SELECT * FROM public.{table} WHERE task_uid = CAST(:uid AS uuid) ORDER BY {order}"),
+                {"uid": uid},
+            ).mappings()
+            collections[name] = [_plain(row) for row in rows]
+        return {
+            **task,
+            "participants": self.participants_for_task(uid),
+            "reviews": self.reviews_for_task(uid),
+            **collections,
+        }
+
+    def overdue_tasks(self, at):
+        rows = self.session.execute(
+            text(
+                self._task_select()
+                + " WHERE status IN ('pending','in_progress','pending_review','reopened') AND due_at <= :at FOR UPDATE SKIP LOCKED"
+            ),
+            {"at": at},
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def create_notification(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_notifications (
+                    uid, event_key, event_type, recipient_uid, channel,
+                    subject, body, related_task_uid, status, attempts,
+                    max_attempts, next_attempt_at, last_error, delivered_at,
+                    read_at, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :event_key, :event_type,
+                    CAST(:recipient_uid AS uuid), :channel, :subject, :body,
+                    CAST(:related_task_uid AS uuid), :status, :attempts,
+                    :max_attempts, :next_attempt_at, :last_error,
+                    :delivered_at, :read_at, :created_at
+                ) ON CONFLICT (event_key) DO NOTHING
+                """
+            ),
+            record,
+        )
+        return copy.deepcopy(record)
+
+    def prepare_notification(self, record, task):
+        preference = self.get_notification_preferences(record["recipient_uid"])
+        channels = set(preference["enabled_channels"])
+        subscribed = set(preference["subscribed_events"])
+        if record["channel"] not in channels or (
+            subscribed and record["event_type"] not in subscribed
+        ):
+            return {**record, "status": "suppressed", "delivered_at": None}
+        template = self.session.execute(
+            text(
+                """
+                SELECT subject_template, body_template
+                FROM public.governance_notification_templates
+                WHERE code = :code AND channel = :channel AND status = 'active'
+                """
+            ),
+            {"code": record["event_type"], "channel": record["channel"]},
+        ).one_or_none()
+        if template is None:
+            return record
+        substitutions = {
+            "{{title}}": task["title"],
+            "{{description}}": task["description"],
+            "{{task_code}}": task["task_code"],
+        }
+        subject, body = template
+        for token, value in substitutions.items():
+            subject = subject.replace(token, value)
+            body = body.replace(token, value)
+        return {**record, "subject": subject, "body": body}
+
+    def list_notification_templates(self):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, code, channel, subject_template,
+                       body_template, status, current_version,
+                       created_by::text AS created_by,
+                       updated_by::text AS updated_by, created_at, updated_at
+                FROM public.governance_notification_templates
+                ORDER BY code, channel
+                """
+            )
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def create_notification_template(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_notification_templates (
+                    uid, code, channel, subject_template, body_template,
+                    status, current_version, created_by, updated_by,
+                    created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :channel, :subject_template,
+                    :body_template, :status, :current_version,
+                    CAST(:created_by AS uuid), CAST(:updated_by AS uuid),
+                    :created_at, :updated_at
+                )
+                """
+            ),
+            record,
+        )
+        return copy.deepcopy(record)
+
+    def revise_notification_template(self, record, expected_version):
+        changed = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_notification_templates SET
+                    subject_template = :subject_template,
+                    body_template = :body_template, 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 changed.rowcount != 1:
+            raise RuntimeError("notification template version conflict")
+        return next(
+            item
+            for item in self.list_notification_templates()
+            if item["uid"] == record["uid"]
+        )
+
+    def get_notification_preferences(self, user_uid):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT user_uid::text AS user_uid, enabled_channels,
+                           subscribed_events, quiet_hours, revision, updated_at
+                    FROM public.governance_notification_preferences
+                    WHERE user_uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": user_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if row:
+            return _plain(row)
+        return {
+            "user_uid": user_uid,
+            "enabled_channels": ["in_app", "email"],
+            "subscribed_events": [],
+            "quiet_hours": {},
+            "revision": 0,
+            "updated_at": None,
+        }
+
+    def replace_notification_preferences(self, record, expected_revision):
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
+            {"key": f"notification-preference:{record['user_uid']}"},
+        )
+        current = self.get_notification_preferences(record["user_uid"])
+        if int(current["revision"]) != expected_revision:
+            raise RuntimeError("notification preference revision conflict")
+        saved = {**record, "revision": expected_revision + 1}
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_notification_preferences (
+                    user_uid, enabled_channels, subscribed_events,
+                    quiet_hours, revision, updated_at
+                ) VALUES (
+                    CAST(:user_uid AS uuid), CAST(:enabled_channels AS jsonb),
+                    CAST(:subscribed_events AS jsonb), CAST(:quiet_hours AS jsonb),
+                    :revision, :updated_at
+                ) ON CONFLICT (user_uid) DO UPDATE SET
+                    enabled_channels = EXCLUDED.enabled_channels,
+                    subscribed_events = EXCLUDED.subscribed_events,
+                    quiet_hours = EXCLUDED.quiet_hours,
+                    revision = EXCLUDED.revision,
+                    updated_at = EXCLUDED.updated_at
+                """
+            ),
+            {
+                **saved,
+                "enabled_channels": json.dumps(saved["enabled_channels"]),
+                "subscribed_events": json.dumps(saved["subscribed_events"]),
+                "quiet_hours": json.dumps(saved["quiet_hours"]),
+            },
+        )
+        return saved
+
+    def list_notifications(self, recipient_uid, unread_only=False):
+        unread = " AND read_at IS NULL" if unread_only else ""
+        rows = self.session.execute(
+            text(
+                """
+                SELECT uid::text AS uid, event_key, event_type,
+                       recipient_uid::text AS recipient_uid, channel, subject,
+                       body, related_task_uid::text AS related_task_uid, status,
+                       attempts, max_attempts, next_attempt_at, last_error,
+                       delivered_at, read_at, created_at
+                FROM public.governance_notifications
+                WHERE recipient_uid = CAST(:uid AS uuid)
+                """ + unread + " ORDER BY created_at DESC, uid DESC"
+            ),
+            {"uid": recipient_uid},
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def claim_notifications(self, channel, at, limit):
+        rows = self.session.execute(
+            text(
+                """
+                WITH selected AS (
+                    SELECT uid FROM public.governance_notifications
+                    WHERE channel = :channel AND status = 'pending'
+                      AND next_attempt_at <= :at
+                    ORDER BY next_attempt_at, created_at
+                    FOR UPDATE SKIP LOCKED LIMIT :limit
+                ), updated AS (
+                    UPDATE public.governance_notifications n SET status = 'processing'
+                    FROM selected WHERE n.uid = selected.uid
+                    RETURNING n.*
+                )
+                SELECT n.uid::text AS uid, n.event_key, n.event_type,
+                       n.recipient_uid::text AS recipient_uid, n.channel,
+                       n.subject, n.body, n.related_task_uid::text AS related_task_uid,
+                       n.status, n.attempts, n.max_attempts, n.next_attempt_at,
+                       n.last_error, n.delivered_at, n.read_at, n.created_at,
+                       CASE WHEN position('@' in u.username) > 1
+                            THEN u.username ELSE NULL END AS recipient_email
+                FROM updated n JOIN public.users u ON u.id = n.recipient_uid
+                """
+            ),
+            {"channel": channel, "at": at, "limit": limit},
+        ).mappings()
+        return [_plain(row) for row in rows]
+
+    def record_delivery(self, notification, result, at):
+        self.session.execute(
+            text(
+                """
+                UPDATE public.governance_notifications SET
+                    status = :status, attempts = :attempts,
+                    next_attempt_at = COALESCE(:available_at, next_attempt_at),
+                    last_error = :last_error,
+                    delivered_at = CASE WHEN :status = 'delivered' THEN :at ELSE delivered_at END
+                WHERE uid = CAST(:uid AS uuid) AND status = 'processing'
+                """
+            ),
+            {
+                "uid": notification["uid"],
+                "status": result.status,
+                "attempts": result.attempts,
+                "available_at": result.available_at,
+                "last_error": result.last_error,
+                "at": at,
+            },
+        )
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_notification_attempts (
+                    uid, notification_uid, attempt, status, safe_error, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:notification_uid AS uuid), :attempt,
+                    :status, :safe_error, :created_at
+                )
+                """
+            ),
+            {
+                "uid": new_governance_uid(),
+                "notification_uid": notification["uid"],
+                "attempt": result.attempts,
+                "status": result.status,
+                "safe_error": result.last_error,
+                "created_at": at,
+            },
+        )
+        return next(
+            item
+            for item in self.list_notifications(notification["recipient_uid"])
+            if item["uid"] == notification["uid"]
+        )
+
+    def mark_notification_read(self, uid, recipient_uid, at):
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.governance_notifications SET read_at = COALESCE(read_at, :at)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND recipient_uid = CAST(:recipient_uid AS uuid)
+                """
+            ),
+            {"uid": uid, "recipient_uid": recipient_uid, "at": at},
+        )
+        if result.rowcount != 1:
+            raise LookupError("notification was not found")
+        return next(
+            item for item in self.list_notifications(recipient_uid) if item["uid"] == uid
+        )
+
+    def dashboard(self, at):
+        summary = self.session.execute(
+            text(
+                """
+                SELECT COUNT(*) AS task_count,
+                       COUNT(*) FILTER (WHERE status IN ('pending','in_progress','pending_review','reopened')) AS pending_count,
+                       COUNT(*) FILTER (WHERE status NOT IN ('closed','cancelled') AND due_at <= :at) AS overdue_count,
+                       COUNT(*) FILTER (WHERE status = 'closed') AS closed_count
+                FROM public.governance_tasks
+                """
+            ),
+            {"at": at},
+        ).mappings().one()
+        by_type = dict(
+            self.session.execute(
+                text(
+                    "SELECT subject_type, COUNT(*) FROM public.governance_tasks GROUP BY subject_type"
+                )
+            ).all()
+        )
+        trend_rows = self.session.execute(
+            text(
+                """
+                SELECT date_trunc('day', created_at)::date::text AS day,
+                       COUNT(*) AS created,
+                       COUNT(*) FILTER (WHERE status = 'closed') AS closed
+                FROM public.governance_tasks
+                WHERE created_at >= :at - INTERVAL '30 days'
+                GROUP BY 1 ORDER BY 1
+                """
+            ),
+            {"at": at},
+        ).all()
+        total = int(summary["task_count"] or 0)
+        closed = int(summary["closed_count"] or 0)
+        return {
+            **{key: int(value or 0) for key, value in summary.items()},
+            "closure_rate": closed / total if total else 0,
+            "by_type": {
+                kind: int(by_type.get(kind, 0))
+                for kind in ("quality_issue", "semantic_governance", "data_product", "agent")
+            },
+            "trend": [
+                {"day": row[0], "created": int(row[1]), "closed": int(row[2])}
+                for row in trend_rows
+            ],
+        }

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

@@ -58,6 +58,9 @@ SEMANTIC_GOVERNANCE_PUBLISH = "semantic-governance:publish"
 DATA_OBSERVABILITY_READ = "data-observability:read"
 DATA_OBSERVABILITY_OPERATE = "data-observability:operate"
 DATA_OBSERVABILITY_MANAGE = "data-observability:manage"
+WORK_CENTER_READ = "governance:work-center:read"
+WORK_CENTER_OPERATE = "governance:work-center:operate"
+WORK_CENTER_MANAGE = "governance:work-center:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -68,6 +71,7 @@ ROLE_PERMISSIONS = {
             DOMAIN_TEMPLATES_READ,
             ACTIVE_METADATA_READ,
             DATA_OBSERVABILITY_READ,
+            WORK_CENTER_READ,
         }
     ),
     "editor": frozenset(
@@ -97,6 +101,8 @@ ROLE_PERMISSIONS = {
             SEMANTIC_GOVERNANCE_EDIT,
             DATA_OBSERVABILITY_READ,
             DATA_OBSERVABILITY_OPERATE,
+            WORK_CENTER_READ,
+            WORK_CENTER_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -152,6 +158,9 @@ ROLE_PERMISSIONS = {
             DATA_OBSERVABILITY_READ,
             DATA_OBSERVABILITY_OPERATE,
             DATA_OBSERVABILITY_MANAGE,
+            WORK_CENTER_READ,
+            WORK_CENTER_OPERATE,
+            WORK_CENTER_MANAGE,
         }
     ),
 }
@@ -168,6 +177,15 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if method == "GET":
             return (RESPONSIBILITIES_READ,)
         return (RESPONSIBILITIES_MANAGE,)
+    if path.startswith("/api/system/work-center"):
+        if method == "GET":
+            return (WORK_CENTER_READ,)
+        if any(
+            segment in path
+            for segment in ("/workflows", "/templates", "/timeouts", "/deliver")
+        ):
+            return (WORK_CENTER_MANAGE,)
+        return (WORK_CENTER_OPERATE,)
     if path.startswith("/api/system/governance-audit"):
         if method == "GET":
             return (GOVERNANCE_AUDIT_READ,)

+ 275 - 0
deployment/migrations/versions/20260802_430_unified_work_center.py

@@ -0,0 +1,275 @@
+"""Add the unified governance approval, task and notification work center."""
+
+from alembic import op
+
+
+revision = "20260802_430"
+down_revision = "20260801_420"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.governance_workflows (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','retired')
+            ),
+            current_version INTEGER NOT NULL CHECK (current_version > 0),
+            active_version_uid UUID,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.governance_workflow_versions (
+            uid UUID PRIMARY KEY,
+            workflow_uid UUID NOT NULL REFERENCES public.governance_workflows(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 (workflow_uid, version),
+            CHECK (jsonb_typeof(definition) = 'object')
+        );
+        ALTER TABLE public.governance_workflows
+            ADD CONSTRAINT governance_workflow_active_version_fk
+            FOREIGN KEY (active_version_uid)
+            REFERENCES public.governance_workflow_versions(uid);
+        CREATE UNIQUE INDEX uq_governance_workflow_published_version
+            ON public.governance_workflow_versions(workflow_uid)
+            WHERE status = 'published';
+
+        CREATE TABLE public.governance_tasks (
+            uid UUID PRIMARY KEY,
+            task_code VARCHAR(40) NOT NULL UNIQUE,
+            workflow_uid UUID NOT NULL REFERENCES public.governance_workflows(uid),
+            workflow_version INTEGER NOT NULL CHECK (workflow_version > 0),
+            task_type VARCHAR(40) NOT NULL CHECK (
+                task_type IN (
+                    'approval','quality_issue','semantic_governance',
+                    'data_product_approval','agent_approval',
+                    'governance_work_order','release','high_risk'
+                )
+            ),
+            subject_type VARCHAR(40) NOT NULL CHECK (
+                subject_type IN (
+                    'quality_issue','semantic_governance','data_product','agent'
+                )
+            ),
+            subject_uid VARCHAR(200) NOT NULL,
+            source_type VARCHAR(80) NOT NULL,
+            source_uid VARCHAR(200) NOT NULL,
+            title VARCHAR(300) NOT NULL,
+            description VARCHAR(2000) NOT NULL,
+            business_domain_uid VARCHAR(200),
+            priority VARCHAR(20) NOT NULL CHECK (
+                priority IN ('low','medium','high','critical')
+            ),
+            status VARCHAR(30) NOT NULL CHECK (
+                status IN (
+                    'pending','in_progress','pending_review','approved',
+                    'rejected','closed','reopened','cancelled'
+                )
+            ),
+            assignee_uid UUID NOT NULL REFERENCES public.users(id),
+            due_at TIMESTAMPTZ NOT NULL,
+            escalation_level INTEGER NOT NULL DEFAULT 0 CHECK (
+                escalation_level BETWEEN 0 AND 10
+            ),
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (
+                current_version > 0
+            ),
+            route_snapshot JSONB NOT NULL,
+            context JSONB NOT NULL DEFAULT '{}'::jsonb,
+            source_state_unchanged BOOLEAN NOT NULL DEFAULT TRUE CHECK (
+                source_state_unchanged
+            ),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            closed_at TIMESTAMPTZ,
+            CHECK (jsonb_typeof(route_snapshot) = 'object'),
+            CHECK (jsonb_typeof(context) = 'object')
+        );
+        CREATE UNIQUE INDEX uq_governance_task_active_source
+            ON public.governance_tasks(source_type, source_uid)
+            WHERE status NOT IN ('closed','cancelled');
+        CREATE INDEX idx_governance_task_worklist
+            ON public.governance_tasks(
+                assignee_uid, status, priority, due_at, created_at DESC
+            );
+        CREATE INDEX idx_governance_task_domain
+            ON public.governance_tasks(
+                business_domain_uid, status, due_at
+            );
+
+        CREATE TABLE public.governance_task_participants (
+            uid UUID PRIMARY KEY,
+            task_uid UUID NOT NULL
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            user_uid UUID NOT NULL REFERENCES public.users(id),
+            sequence INTEGER NOT NULL CHECK (sequence > 0),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('pending','decided','transferred')
+            ),
+            transferred_from_uid UUID REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (task_uid, user_uid)
+        );
+        CREATE INDEX idx_governance_task_participant_user
+            ON public.governance_task_participants(user_uid, status, task_uid);
+
+        CREATE TABLE public.governance_task_reviews (
+            uid UUID PRIMARY KEY,
+            task_uid UUID NOT NULL
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            reviewer_uid UUID NOT NULL REFERENCES public.users(id),
+            decision VARCHAR(20) NOT NULL CHECK (
+                decision IN ('approve','reject')
+            ),
+            reason VARCHAR(1000) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (task_uid, reviewer_uid)
+        );
+
+        CREATE TABLE public.governance_task_comments (
+            uid UUID PRIMARY KEY,
+            task_uid UUID NOT NULL
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            content VARCHAR(4000) NOT NULL,
+            mentions JSONB NOT NULL DEFAULT '[]'::jsonb,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(mentions) = 'array')
+        );
+
+        CREATE TABLE public.governance_task_attachments (
+            uid UUID PRIMARY KEY,
+            task_uid UUID NOT NULL
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            name VARCHAR(300) NOT NULL,
+            storage_ref VARCHAR(1000) NOT NULL,
+            content_hash CHAR(64) NOT NULL,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (task_uid, content_hash)
+        );
+
+        CREATE TABLE public.governance_task_events (
+            uid UUID PRIMARY KEY,
+            task_uid UUID NOT NULL
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            task_version INTEGER NOT NULL CHECK (task_version > 0),
+            action VARCHAR(40) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            before_state JSONB NOT NULL DEFAULT '{}'::jsonb,
+            after_state JSONB NOT NULL,
+            payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (task_uid, task_version, action),
+            CHECK (jsonb_typeof(before_state) = 'object'),
+            CHECK (jsonb_typeof(after_state) = 'object'),
+            CHECK (jsonb_typeof(payload) = 'object')
+        );
+        CREATE INDEX idx_governance_task_event_timeline
+            ON public.governance_task_events(task_uid, created_at, uid);
+
+        CREATE TABLE public.governance_notification_templates (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL,
+            channel VARCHAR(20) NOT NULL CHECK (
+                channel IN ('in_app','email')
+            ),
+            subject_template VARCHAR(500) NOT NULL,
+            body_template VARCHAR(4000) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('active','retired')
+            ),
+            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,
+            UNIQUE (code, channel)
+        );
+
+        CREATE TABLE public.governance_notification_preferences (
+            user_uid UUID PRIMARY KEY REFERENCES public.users(id),
+            enabled_channels JSONB NOT NULL DEFAULT '["in_app","email"]'::jsonb,
+            subscribed_events JSONB NOT NULL DEFAULT '[]'::jsonb,
+            quiet_hours JSONB NOT NULL DEFAULT '{}'::jsonb,
+            revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(enabled_channels) = 'array'),
+            CHECK (jsonb_typeof(subscribed_events) = 'array'),
+            CHECK (jsonb_typeof(quiet_hours) = 'object')
+        );
+
+        CREATE TABLE public.governance_notifications (
+            uid UUID PRIMARY KEY,
+            event_key VARCHAR(500) NOT NULL UNIQUE,
+            event_type VARCHAR(80) NOT NULL,
+            recipient_uid UUID NOT NULL REFERENCES public.users(id),
+            channel VARCHAR(20) NOT NULL CHECK (
+                channel IN ('in_app','email')
+            ),
+            subject VARCHAR(500) NOT NULL,
+            body VARCHAR(4000) NOT NULL,
+            related_task_uid UUID
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN (
+                    'pending','processing','delivered','suppressed','dead_letter'
+                )
+            ),
+            attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
+            max_attempts INTEGER NOT NULL DEFAULT 5 CHECK (max_attempts > 0),
+            next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            last_error VARCHAR(1000),
+            delivered_at TIMESTAMPTZ,
+            read_at TIMESTAMPTZ,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_governance_notification_inbox
+            ON public.governance_notifications(
+                recipient_uid, channel, read_at, created_at DESC
+            );
+        CREATE INDEX idx_governance_notification_delivery
+            ON public.governance_notifications(
+                channel, status, next_attempt_at
+            );
+
+        CREATE TABLE public.governance_notification_attempts (
+            uid UUID PRIMARY KEY,
+            notification_uid UUID NOT NULL
+                REFERENCES public.governance_notifications(uid) ON DELETE CASCADE,
+            attempt INTEGER NOT NULL CHECK (attempt > 0),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('pending','delivered','dead_letter')
+            ),
+            safe_error VARCHAR(1000),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (notification_uid, attempt)
+        );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "workflow, task and notification evidence is retained; "
+        "downgrade requires an approved archival migration"
+    )

+ 12 - 5
docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md

@@ -358,11 +358,11 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 
 **主要工作:**
 
-- [ ] 定义通用流程、条件路由、会签/或签、双人复核和转签。
-- [ ] 建设统一待办,接入质量问题、术语标准、数据产品和 Agent 审批。
-- [ ] 建设治理工单、评论、附件、时间线和重开。
-- [ ] 建设站内消息、邮件模板、重试、送达状态和审计。
-- [ ] 建设待办、逾期、闭环率和趋势看板。
+- [x] 定义通用流程、条件路由、会签/或签、双人复核和转签。
+- [x] 建设统一待办,接入质量问题、术语标准、数据产品和 Agent 审批。
+- [x] 建设治理工单、评论、附件、时间线和重开。
+- [x] 建设站内消息、邮件模板、重试、送达状态和审计。
+- [x] 建设待办、逾期、闭环率和趋势看板。
 
 **主要文件区域:**
 
@@ -376,6 +376,13 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 **完成门禁:** 至少四类任务使用同一待办契约;超时升级、失败重试和关闭均有
 可审计状态,不建设企业微信、飞书、钉钉或 ITSM 正式集成。
 
+**工程状态:** 已完成本地工程门禁。质量问题、术语标准、数据产品和 Agent 四类事项
+共用统一任务契约;流程版本、条件路由、或签/会签、双人复核、转签、评论、附件、关闭、
+重开、逾期升级、通知模板、订阅偏好、邮件重试与运营看板均已落库并提供权限隔离接口。
+统一工作中心只形成处理证据与 outbox 回执,不直接改写源模块状态。企业邮件目录与 SMTP、
+正式定时调度、真实流程参与人和四类业务端到端 UAT 仍需环境绑定;未建设企业微信、飞书、
+钉钉或 ITSM 正式集成。详见 `docs/phase2/P2_WP07_UNIFIED_WORK_CENTER.md`。
+
 ### P2-WP08 基础数据产品治理
 
 **目标:** 把已有数据产品和订单能力升级为受责任、合同和质量约束的治理对象。

+ 28 - 0
docs/architecture/DATA_MODEL.md

@@ -379,6 +379,33 @@ canonical 数据。画像与异常只用于数据运营与治理,不提供任
 服务视图读取 outbox 积压/失败,任务与数据视图读取事故告警;容量只展示数据库当前用量,
 企业容量上限未配置时返回未知,不填造健康结论。
 
+## 4.8 P2-WP07 统一审批、任务与通知
+
+统一工作中心是质量问题、术语标准、数据产品和 Agent 四类治理事项之上的薄任务层。
+源模块继续保存业务对象和状态;工作中心保存流程版本、路由快照、参与人、处理证据、
+通知送达和运营度量。任务状态变化写出 `governance.task.evidence.recorded` outbox 事件,
+供源模块在自身规则内消费,不由工作中心跨边界直接修改源对象。
+
+| 数据对象 | 作用 | 关键约束 |
+|---|---|---|
+| `governance_workflows` | 通用流程身份与活动版本 | 编码唯一,活动版本显式引用 |
+| `governance_workflow_versions` | 不可变路由版本 | 条件路由、或签/会签、双人复核、期限和通知渠道快照 |
+| `governance_tasks` | 四类事项的统一待办当前态 | 源类型和源编号保证未关闭任务幂等;乐观版本控制;`source_state_unchanged=true` |
+| `governance_task_participants` | 审批参与人和转签链 | 参与人唯一,保留原转出人 |
+| `governance_task_reviews` | 审批决定 | 同一任务同一审批人只能决定一次 |
+| `governance_task_comments` | 工单评论 | 评论追加保存,提及用户必须有效 |
+| `governance_task_attachments` | 附件证据索引 | 只保存对象存储引用和 SHA-256,不在数据库复制附件原件 |
+| `governance_task_events` | 不可变处理时间线 | 每次状态版本、前后快照和动作载荷可审计 |
+| `governance_notification_templates` | 站内信和邮件模板 | 编码与渠道唯一,模板修订采用乐观版本 |
+| `governance_notification_preferences` | 用户渠道和事件订阅 | 用户级修订号,未配置时使用安全默认值 |
+| `governance_notifications` | 消息与送达当前态 | 事件键幂等;待投递、处理中、已送达、已抑制和死信显式区分 |
+| `governance_notification_attempts` | 外部投递尝试 | 每次尝试追加记录,错误脱敏,失败指数退避并最终进入死信 |
+
+条件路由只接受业务域、风险级别、敏感级别、环境和金额五类结构化字段,不执行脚本或
+任意表达式。双人复核至少需要两名独立审批人,任务创建人不能在双人复核中自审。统一
+工作台按用户参与范围过滤任务,管理员才能配置流程、模板、超时批次和外部邮件投递。
+当前只提供 SMTP 绑定和可审计重试,不包含企业微信、飞书、钉钉或 ITSM 正式连接器。
+
 ## 5. 所有权与删除规则
 
 - PostgreSQL 是身份、权限、映射、任务状态、布局和一致性事件的源真相。
@@ -400,6 +427,7 @@ canonical 数据。画像与异常只用于数据运营与治理,不提供任
 - 业务术语、通用代码集、指标口径、物理字段映射、不可变版本、独立审批和发布审计以 PostgreSQL 为源真相;标准版本继续复用既有不可变发布门禁。指标口径不是执行计划,发布后通过 outbox 同步 canonical 治理知识,知识同步配置未就绪时事件不得丢失。
 - 通用质量模板、版本、画像批次、字段指标、异常发现和 SLA 事件以 PostgreSQL 为源真相;主动元数据资产是质量对象身份和根因证据的权威来源。复发次数和升级级别为确定性运营证据,不等同于自动因果结论或自动修复授权。
 - SLO、源事件消费账本、聚合告警、数据事故、影响、处置时间线和复盘以 PostgreSQL 为源真相;采集和质量原始事件仍由其原表负责。事故恢复不等于关闭,缺少责任、影响或关闭证据时必须失败关闭,事故证据不得物理删除。
+- 通用流程版本、统一任务、参与人、审批、评论、附件引用、处理时间线、通知模板、偏好和送达尝试以 PostgreSQL 为源真相;质量问题、术语标准、数据产品和 Agent 本身的状态仍归各自源模块。工作中心只写处理证据和 outbox 回执,不把审批结果直接冒充源模块状态。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 521 - 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: 314
+x-route-count: 334
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -8099,6 +8099,526 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/work-center/dashboard":
+    get:
+      tags: [system]
+      operationId: system_get_work_center_dashboard_get
+      summary: "get work center dashboard"
+      x-source: "app/api/system/work_center.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/work-center/notifications":
+    get:
+      tags: [system]
+      operationId: system_list_work_center_notifications_get
+      summary: "list work center notifications"
+      x-source: "app/api/system/work_center.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/work-center/notifications/deliver":
+    post:
+      tags: [system]
+      operationId: system_deliver_work_center_notifications_post
+      summary: "deliver work center notifications"
+      x-source: "app/api/system/work_center.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/work-center/notifications/{notification_uid}/read":
+    post:
+      tags: [system]
+      operationId: system_read_work_center_notification_post
+      summary: "read work center notification"
+      x-source: "app/api/system/work_center.py"
+      parameters:
+        - name: notification_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/work-center/preferences":
+    get:
+      tags: [system]
+      operationId: system_get_work_center_preferences_get
+      summary: "get work center preferences"
+      x-source: "app/api/system/work_center.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    put:
+      tags: [system]
+      operationId: system_replace_work_center_preferences_put
+      summary: "replace work center preferences"
+      x-source: "app/api/system/work_center.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/work-center/tasks":
+    get:
+      tags: [system]
+      operationId: system_list_work_center_tasks_get
+      summary: "list work center tasks"
+      x-source: "app/api/system/work_center.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_work_center_task_post
+      summary: "create work center task"
+      x-source: "app/api/system/work_center.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/work-center/tasks/{task_uid}":
+    get:
+      tags: [system]
+      operationId: system_get_work_center_task_get
+      summary: "get work center task"
+      x-source: "app/api/system/work_center.py"
+      parameters:
+        - name: task_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/work-center/tasks/{task_uid}/attachments":
+    post:
+      tags: [system]
+      operationId: system_add_work_center_attachment_post
+      summary: "add work center attachment"
+      x-source: "app/api/system/work_center.py"
+      parameters:
+        - name: task_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/work-center/tasks/{task_uid}/comments":
+    post:
+      tags: [system]
+      operationId: system_add_work_center_comment_post
+      summary: "add work center comment"
+      x-source: "app/api/system/work_center.py"
+      parameters:
+        - name: task_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/work-center/tasks/{task_uid}/{action}":
+    post:
+      tags: [system]
+      operationId: system_operate_work_center_task_post
+      summary: "operate work center task"
+      x-source: "app/api/system/work_center.py"
+      parameters:
+        - name: task_uid
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: action
+          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/work-center/templates":
+    get:
+      tags: [system]
+      operationId: system_list_work_center_templates_get
+      summary: "list work center templates"
+      x-source: "app/api/system/work_center.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_work_center_template_post
+      summary: "create work center template"
+      x-source: "app/api/system/work_center.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/work-center/templates/{template_uid}":
+    patch:
+      tags: [system]
+      operationId: system_revise_work_center_template_patch
+      summary: "revise work center template"
+      x-source: "app/api/system/work_center.py"
+      parameters:
+        - name: template_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/work-center/timeouts/process":
+    post:
+      tags: [system]
+      operationId: system_process_work_center_timeouts_post
+      summary: "process work center timeouts"
+      x-source: "app/api/system/work_center.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/work-center/workflows":
+    get:
+      tags: [system]
+      operationId: system_list_work_center_workflows_get
+      summary: "list work center workflows"
+      x-source: "app/api/system/work_center.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_work_center_workflow_post
+      summary: "create work center workflow"
+      x-source: "app/api/system/work_center.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/work-center/workflows/{workflow_uid}/publish":
+    post:
+      tags: [system]
+      operationId: system_publish_work_center_workflow_post
+      summary: "publish work center workflow"
+      x-source: "app/api/system/work_center.py"
+      parameters:
+        - name: workflow_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/work-center/workflows/{workflow_uid}/revisions":
+    post:
+      tags: [system]
+      operationId: system_revise_work_center_workflow_post
+      summary: "revise work center workflow"
+      x-source: "app/api/system/work_center.py"
+      parameters:
+        - name: workflow_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/workbench/layout":
     get:
       tags: [system]

+ 76 - 0
docs/phase2/P2_WP07_UNIFIED_WORK_CENTER.md

@@ -0,0 +1,76 @@
+# P2-WP07 统一审批、任务与通知工程说明
+
+## 1. 完成范围
+
+P2-WP07 已完成本地工程实现和定向验证。平台新增一个薄的统一工作中心,用同一任务
+契约承载质量问题、术语标准、数据产品和 Agent 审批,并保持各源模块的业务状态机不变。
+
+本工作包完成:
+
+- 通用流程草稿、不可变修订和发布,支持结构化条件路由;
+- 单人审批、或签、会签、双人复核、最小同意数和审批人转签;
+- 四类事项统一待办、用户参与范围过滤、乐观版本和活动源事项幂等;
+- 治理工单评论、附件证据索引、处理时间线、关闭和重开;
+- 站内消息、邮件模板、用户渠道/事件偏好、送达状态、指数退避重试和死信;
+- 待办数、逾期数、关闭数、闭环率、业务类型分布和 30 天趋势;
+- 首页“待处理审核”组件接入真实待办统计,数据审核菜单新增统一工作中心。
+
+## 2. 边界与源模块接入
+
+统一工作中心不是新的通用流程引擎。质量问题、语义资产、数据产品/订单和治理 Agent
+仍由各自模块决定业务状态。四类事项通过 `subject_type + subject_uid + source_type +
+source_uid` 接入同一个任务创建契约;同一个未关闭源事项只能存在一条活动任务。
+
+审批、关闭、重开和超时处理只更新统一任务并追加时间线,同时写出
+`governance.task.evidence.recorded` outbox 事件。源模块可在自己的权限、责任和状态门禁内
+消费该回执;工作中心显式保存 `source_state_unchanged=true`,不会直接把统一任务结果
+写成源对象状态。该约束避免质量、术语、产品和 Agent 各自再复制一套审批状态机。
+
+## 3. 流程、并发与审计
+
+迁移 `20260802_430` 新增流程、任务、协作、通知和送达尝试共 12 张表。流程路由只接受
+业务域、风险、敏感等级、环境和金额五类结构化条件以及 `eq/in/gte/lte` 运算,不执行
+脚本。任务在创建时固定流程版本和路由快照,后续流程修订不改写历史任务。
+
+任务状态更新要求 `If-Match` 当前版本;审批、转签、关闭、重开、超时升级和超时关闭
+均追加带前后快照的审计事件。双人复核至少需要两名独立审批人,创建人不能自审。评论、
+附件和任务详情只对创建人、当前处理人、参与人或管理员可见,普通用户不能通过任务编号
+旁路读取或操作其他人的事项。
+
+## 4. 通知与运营视图
+
+站内消息在事务内直接形成已送达记录;邮件进入待投递队列。投递批次使用跳过锁定领取,
+失败错误先脱敏,再按指数退避重试;达到最大次数后进入死信。邮件模板支持任务编号、标题
+和描述占位符。用户可以关闭渠道或限定订阅事件,被过滤的消息保存为 `suppressed`,而不是
+静默丢弃。
+
+SMTP 只在 `DATAOPS_SMTP_HOST`、`DATAOPS_SMTP_FROM`、端口和可选登录信息完成绑定后发送。
+当前用户目录没有独立邮件字段,测试绑定使用符合邮件格式的用户名;企业实施必须改为正式
+身份目录邮件属性并完成送达 UAT。平台未接入企业微信、飞书、钉钉或 ITSM。
+
+统一工作台展示我的待办、消息、流程和模板,以及任务总数、待办、逾期和闭环率。统计来自
+持久化任务,不以页面缓存或模拟数据代替。
+
+## 5. 权限与接口
+
+- `governance:work-center:read`:查看本人相关待办、消息、流程摘要和运营统计;
+- `governance:work-center:operate`:创建事项、审批、转签、评论、附件、关闭/重开和偏好;
+- `governance:work-center:manage`:流程/模板管理、全局任务视图、超时批次和邮件投递。
+
+OpenAPI 已由当前源码重新生成,共 334 个操作,其中统一工作中心提供 16 个路由路径,覆盖
+流程、任务、协作、通知、模板、偏好、超时和看板。
+
+## 6. 定向验证与剩余门禁
+
+本工作包按约束只运行本次变动相关验证:
+
+- 核心、API、权限、迁移和前端契约;
+- 本次 Python 文件 Ruff 和前端改动文件 ESLint;
+- 本地 PostgreSQL 从 `20260731_400` 实际升级到 `20260802_430`;
+- PostgreSQL 中创建并发布流程,生成四类统一任务,完成审批、关闭、审计、outbox、模板、
+  用户偏好、抑制和邮件失败重试验证;
+- OpenAPI 可重复生成和发布目录源码同步后的一致性检查。
+
+正式生产就绪仍需:绑定企业身份目录邮件属性和 SMTP;配置正式流程、参与人、期限与定时
+调度;由质量、语义、产品和 Agent 各源模块消费任务回执;完成四类真实事项端到端 UAT、
+容量与告警验证。以上完成前,本地工程完成不等同于企业验收或生产就绪。

+ 30 - 0
frontend/src/api/workCenter.js

@@ -0,0 +1,30 @@
+import http from '@/utils/request'
+
+const root = '/system/work-center'
+const versionHeaders = version => ({ headers: { 'If-Match': `"${version}"` } })
+
+export const listWorkCenterWorkflows = () => http.get(`${root}/workflows`)
+export const createWorkCenterWorkflow = payload => http.post(`${root}/workflows`, payload)
+export const reviseWorkCenterWorkflow = (uid, payload, version) => http.post(`${root}/workflows/${uid}/revisions`, payload, versionHeaders(version))
+export const publishWorkCenterWorkflow = (uid, version) => http.post(`${root}/workflows/${uid}/publish`, {}, versionHeaders(version))
+
+export const listWorkCenterTasks = params => http.get(`${root}/tasks`, params)
+export const createWorkCenterTask = payload => http.post(`${root}/tasks`, payload)
+export const getWorkCenterTask = uid => http.get(`${root}/tasks/${uid}`)
+export const reviewWorkCenterTask = (uid, payload, version) => http.post(`${root}/tasks/${uid}/review`, payload, versionHeaders(version))
+export const transferWorkCenterTask = (uid, payload, version) => http.post(`${root}/tasks/${uid}/transfer`, payload, versionHeaders(version))
+export const closeWorkCenterTask = (uid, payload, version) => http.post(`${root}/tasks/${uid}/close`, payload, versionHeaders(version))
+export const reopenWorkCenterTask = (uid, payload, version) => http.post(`${root}/tasks/${uid}/reopen`, payload, versionHeaders(version))
+export const addWorkCenterComment = (uid, payload) => http.post(`${root}/tasks/${uid}/comments`, payload)
+export const addWorkCenterAttachment = (uid, payload) => http.post(`${root}/tasks/${uid}/attachments`, payload)
+
+export const processWorkCenterTimeouts = payload => http.post(`${root}/timeouts/process`, payload)
+export const listWorkCenterNotifications = params => http.get(`${root}/notifications`, params)
+export const readWorkCenterNotification = uid => http.post(`${root}/notifications/${uid}/read`)
+export const deliverWorkCenterNotifications = payload => http.post(`${root}/notifications/deliver`, payload)
+export const listWorkCenterTemplates = () => http.get(`${root}/templates`)
+export const createWorkCenterTemplate = payload => http.post(`${root}/templates`, payload)
+export const reviseWorkCenterTemplate = (uid, payload, version) => http.patch(`${root}/templates/${uid}`, payload, versionHeaders(version))
+export const getWorkCenterPreferences = () => http.get(`${root}/preferences`)
+export const replaceWorkCenterPreferences = (payload, revision) => http.put(`${root}/preferences`, payload, versionHeaders(revision))
+export const getWorkCenterDashboard = params => http.get(`${root}/dashboard`, params)

+ 27 - 4
frontend/src/router/routes.js

@@ -1761,13 +1761,14 @@ export default {
       icon: 'mdi-widgets-outline',
       type: 0,
       path: '/dataReview',
+      redirect: '/dataReview/work-center',
       children: [
         {
           hidden: 1,
           icon: '',
           type: 1,
           title: '数据审核',
-          path: '/dataReview',
+          path: '/dataReview/metadata',
           children: [],
           enName: 'data Review',
           redirect: '',
@@ -1782,12 +1783,34 @@ export default {
             title: '数据审核',
             fullScreen: false
           },
-          name: 'index',
+          name: 'metadataReview',
+          alwaysShow: 0
+        },
+        {
+          hidden: 0,
+          icon: 'mdi-inbox-multiple-outline',
+          type: 1,
+          title: '统一工作中心',
+          path: '/dataReview/work-center',
+          children: [],
+          enName: 'Unified Work Center',
+          redirect: '',
+          active: '',
+          label: '统一工作中心',
+          sort: 1,
+          component: 'dataReview/workCenter',
+          meta: {
+            roles: ['governance:work-center:read'],
+            enName: 'Unified Work Center',
+            icon: 'mdi-inbox-multiple-outline',
+            title: '统一工作中心',
+            fullScreen: false
+          },
+          name: 'unifiedWorkCenter',
           alwaysShow: 0
         }
       ],
       enName: 'data Review',
-      redirect: '',
       active: '',
       sort: 0,
       component: 'Layout',
@@ -1796,7 +1819,7 @@ export default {
         roles: [],
         enName: 'data Review',
         icon: 'mdi-widgets-outline',
-        title: '数据审核',
+        title: '治理工作',
         target: false,
         effectiveStatus: true
       },

+ 234 - 0
frontend/src/views/dataReview/workCenter.vue

@@ -0,0 +1,234 @@
+<template>
+  <div class="work-center pa-4">
+    <div class="hero pa-5 mb-4">
+      <div>
+        <div class="eyebrow mb-2">GOVERNANCE OPERATIONS</div>
+        <h1 class="text-h4 font-weight-bold mb-2">统一工作中心</h1>
+        <p class="mb-0">集中处理审批、治理工单与通知;业务对象状态仍由原模块负责。</p>
+      </div>
+      <v-btn color="white" outlined :loading="loading" @click="refreshAll">
+        <v-icon left>mdi-refresh</v-icon>刷新
+      </v-btn>
+    </div>
+
+    <v-row class="mb-1">
+      <v-col v-for="card in summaryCards" :key="card.label" cols="6" md="3">
+        <v-card outlined class="metric-card pa-4">
+          <div class="text-caption grey--text text--darken-1">{{ card.label }}</div>
+          <div class="text-h4 font-weight-bold mt-1" :class="`${card.color}--text`">{{ card.value }}</div>
+          <div class="text-caption mt-1">{{ card.note }}</div>
+        </v-card>
+      </v-col>
+    </v-row>
+
+    <v-card outlined>
+      <v-tabs v-model="tab" color="primary" show-arrows>
+        <v-tab>我的待办</v-tab>
+        <v-tab>消息中心</v-tab>
+        <v-tab>流程与模板</v-tab>
+      </v-tabs>
+      <v-divider />
+
+      <v-tabs-items v-model="tab">
+        <v-tab-item>
+          <div class="pa-4 d-flex flex-wrap align-center gap-2">
+            <v-select v-model="filters.subject_type" :items="subjectOptions" label="业务类型" clearable dense outlined hide-details class="filter-control" />
+            <v-select v-model="filters.status" :items="statusOptions" label="任务状态" clearable dense outlined hide-details class="filter-control" />
+            <v-btn color="primary" depressed @click="loadTasks">查询</v-btn>
+          </div>
+          <v-data-table :headers="headers" :items="tasks" :loading="loading" :items-per-page="10">
+            <template v-slot:[`item.subject_type`]="{ item }">
+              <v-chip small outlined :color="typeMeta(item.subject_type).color">{{ typeMeta(item.subject_type).label }}</v-chip>
+            </template>
+            <template v-slot:[`item.status`]="{ item }">
+              <v-chip small :color="statusColor(item.status)" dark>{{ statusLabel(item.status) }}</v-chip>
+            </template>
+            <template v-slot:[`item.due_at`]="{ item }">{{ formatTime(item.due_at) }}</template>
+            <template v-slot:[`item.actions`]="{ item }">
+              <v-btn text small color="primary" @click="openTask(item)">查看</v-btn>
+            </template>
+          </v-data-table>
+        </v-tab-item>
+
+        <v-tab-item>
+          <v-list two-line>
+            <template v-for="message in notifications">
+              <v-list-item :key="message.uid" :class="{ unread: !message.read_at }" @click="markRead(message)">
+                <v-list-item-avatar><v-icon :color="message.read_at ? 'grey' : 'primary'">mdi-bell-outline</v-icon></v-list-item-avatar>
+                <v-list-item-content>
+                  <v-list-item-title>{{ message.subject }}</v-list-item-title>
+                  <v-list-item-subtitle>{{ message.body }}</v-list-item-subtitle>
+                </v-list-item-content>
+                <v-list-item-action><v-chip x-small outlined>{{ message.channel }}</v-chip></v-list-item-action>
+              </v-list-item>
+              <v-divider :key="`${message.uid}-divider`" />
+            </template>
+            <v-list-item v-if="!notifications.length"><v-list-item-content class="text-center grey--text">暂无消息</v-list-item-content></v-list-item>
+          </v-list>
+        </v-tab-item>
+
+        <v-tab-item>
+          <v-row class="pa-4">
+            <v-col cols="12" md="7">
+              <div class="text-subtitle-1 font-weight-medium mb-3">流程版本</div>
+              <v-simple-table><tbody>
+                <tr v-for="flow in workflows" :key="flow.uid">
+                  <td><strong>{{ flow.name }}</strong><div class="text-caption grey--text">{{ flow.code }}</div></td>
+                  <td>v{{ flow.current_version }}</td>
+                  <td><v-chip x-small outlined>{{ flow.status }}</v-chip></td>
+                </tr>
+                <tr v-if="!workflows.length"><td colspan="3" class="grey--text">尚未配置流程</td></tr>
+              </tbody></v-simple-table>
+            </v-col>
+            <v-col cols="12" md="5">
+              <div class="text-subtitle-1 font-weight-medium mb-3">通知模板</div>
+              <v-list dense outlined>
+                <v-list-item v-for="template in templates" :key="template.uid">
+                  <v-list-item-content>
+                    <v-list-item-title>{{ template.code }}</v-list-item-title>
+                    <v-list-item-subtitle>{{ template.channel }} · v{{ template.current_version }}</v-list-item-subtitle>
+                  </v-list-item-content>
+                </v-list-item>
+                <v-list-item v-if="!templates.length"><v-list-item-content class="grey--text">尚未配置模板,将使用任务摘要</v-list-item-content></v-list-item>
+              </v-list>
+            </v-col>
+          </v-row>
+        </v-tab-item>
+      </v-tabs-items>
+    </v-card>
+
+    <v-dialog v-model="detailVisible" max-width="860">
+      <v-card v-if="detail">
+        <v-card-title class="d-flex justify-space-between">
+          <span>{{ detail.title }}</span><v-chip small>{{ statusLabel(detail.status) }}</v-chip>
+        </v-card-title>
+        <v-card-text>
+          <v-alert type="info" text dense>源对象 {{ detail.source_type }} / {{ detail.source_uid }};本中心只记录处理证据。</v-alert>
+          <p>{{ detail.description }}</p>
+          <div class="text-subtitle-2 mb-2">处理时间线</div>
+          <v-timeline dense>
+            <v-timeline-item v-for="event in detail.timeline" :key="event.uid || `${event.action}-${event.created_at}`" small>
+              <strong>{{ event.action }}</strong><div class="text-caption">{{ formatTime(event.created_at) }}</div>
+            </v-timeline-item>
+          </v-timeline>
+        </v-card-text>
+        <v-card-actions>
+          <v-btn v-if="canReview(detail)" text color="error" @click="review(detail, 'reject')">驳回</v-btn>
+          <v-btn v-if="canReview(detail)" depressed color="success" @click="review(detail, 'approve')">同意</v-btn>
+          <v-spacer />
+          <v-btn text @click="detailVisible = false">关闭</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  getWorkCenterDashboard,
+  getWorkCenterTask,
+  listWorkCenterNotifications,
+  listWorkCenterTasks,
+  listWorkCenterTemplates,
+  listWorkCenterWorkflows,
+  readWorkCenterNotification,
+  reviewWorkCenterTask
+} from '@/api/workCenter'
+
+export default {
+  name: 'UnifiedWorkCenter',
+  data: () => ({
+    tab: 0,
+    loading: false,
+    detailVisible: false,
+    detail: null,
+    dashboard: {},
+    tasks: [],
+    notifications: [],
+    workflows: [],
+    templates: [],
+    filters: { subject_type: null, status: null },
+    subjectOptions: [
+      { text: '质量问题', value: 'quality_issue' },
+      { text: '术语标准', value: 'semantic_governance' },
+      { text: '数据产品', value: 'data_product' },
+      { text: 'Agent 审批', value: 'agent' }
+    ],
+    statusOptions: [
+      { text: '待处理', value: 'pending' },
+      { text: '已批准', value: 'approved' },
+      { text: '已驳回', value: 'rejected' },
+      { text: '已关闭', value: 'closed' },
+      { text: '已重开', value: 'reopened' }
+    ],
+    headers: [
+      { text: '编号', value: 'task_code' },
+      { text: '事项', value: 'title' },
+      { text: '业务类型', value: 'subject_type' },
+      { text: '状态', value: 'status' },
+      { text: '到期时间', value: 'due_at' },
+      { text: '', value: 'actions', sortable: false }
+    ]
+  }),
+  computed: {
+    summaryCards () {
+      return [
+        { label: '全部任务', value: this.dashboard.task_count || 0, note: '统一契约覆盖四类事项', color: 'indigo' },
+        { label: '我的待办', value: this.dashboard.pending_count || 0, note: '等待处理或复核', color: 'blue' },
+        { label: '逾期待办', value: this.dashboard.overdue_count || 0, note: '已进入升级或关闭规则', color: 'deep-orange' },
+        { label: '闭环率', value: `${Math.round((this.dashboard.closure_rate || 0) * 100)}%`, note: '基于已关闭任务', color: 'teal' }
+      ]
+    }
+  },
+  created () { this.refreshAll() },
+  methods: {
+    async refreshAll () {
+      this.loading = true
+      try {
+        const [dashboard, tasks, messages, workflows, templates] = await Promise.all([
+          getWorkCenterDashboard(), listWorkCenterTasks(this.filters),
+          listWorkCenterNotifications(), listWorkCenterWorkflows(), listWorkCenterTemplates()
+        ])
+        this.dashboard = dashboard.data || {}
+        this.tasks = tasks.data || []
+        this.notifications = messages.data || []
+        this.workflows = workflows.data || []
+        this.templates = templates.data || []
+      } catch (error) { this.$snackbar.error(error) } finally { this.loading = false }
+    },
+    async loadTasks () {
+      this.loading = true
+      try { const { data } = await listWorkCenterTasks(this.filters); this.tasks = data || [] } catch (error) { this.$snackbar.error(error) } finally { this.loading = false }
+    },
+    async openTask (item) {
+      try { const { data } = await getWorkCenterTask(item.uid); this.detail = data; this.detailVisible = true } catch (error) { this.$snackbar.error(error) }
+    },
+    async review (item, decision) {
+      try {
+        await reviewWorkCenterTask(item.uid, { decision, reason: decision === 'approve' ? '工作中心审核通过' : '工作中心审核驳回' }, item.current_version)
+        this.$snackbar.success('审核结果已记录')
+        this.detailVisible = false
+        await this.refreshAll()
+      } catch (error) { this.$snackbar.error(error) }
+    },
+    async markRead (message) {
+      if (message.read_at) return
+      try { await readWorkCenterNotification(message.uid); message.read_at = new Date().toISOString() } catch (error) { this.$snackbar.error(error) }
+    },
+    canReview (item) { return ['pending', 'in_progress', 'pending_review', 'reopened'].includes(item.status) },
+    typeMeta (type) { return { quality_issue: { label: '质量问题', color: 'red' }, semantic_governance: { label: '术语标准', color: 'purple' }, data_product: { label: '数据产品', color: 'teal' }, agent: { label: 'Agent 审批', color: 'indigo' } }[type] || { label: type, color: 'grey' } },
+    statusColor (status) { return { pending: 'primary', approved: 'success', rejected: 'error', closed: 'grey', reopened: 'warning' }[status] || 'blue-grey' },
+    statusLabel (status) { return { pending: '待处理', approved: '已批准', rejected: '已驳回', closed: '已关闭', reopened: '已重开' }[status] || status },
+    formatTime (value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-' }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.work-center { min-height: 100%; background: #f5f7fb; }
+.hero { display: flex; align-items: center; justify-content: space-between; color: white; border-radius: 16px; background: linear-gradient(125deg, #172554 0%, #3730a3 58%, #2563eb 100%); box-shadow: 0 14px 32px rgba(30, 64, 175, .18); }
+.eyebrow { font-size: 11px; letter-spacing: .18em; opacity: .72; }
+.metric-card { border-radius: 12px; border-top: 3px solid #e0e7ff !important; }
+.filter-control { max-width: 210px; margin-right: 10px; }
+.unread { background: #eef2ff; }
+</style>

+ 17 - 2
frontend/src/views/home/index.vue

@@ -22,7 +22,11 @@
           </v-card-title>
           <v-card-text>
             <div class="text-body-2">{{ widget.description }}</div>
-            <v-chip small outlined class="mt-4">待接入业务数据</v-chip>
+            <template v-if="widget.id === 'pending-review'">
+              <div class="text-h4 font-weight-bold indigo--text mt-3">{{ pendingCount }}</div>
+              <v-btn text small color="primary" class="px-0" @click="$router.push('/dataReview/work-center')">进入统一待办</v-btn>
+            </template>
+            <v-chip v-else small outlined class="mt-4">待接入业务数据</v-chip>
           </v-card-text>
         </v-card>
       </v-col>
@@ -36,12 +40,14 @@
 
 <script>
 import { loadWorkbenchLayout, STANDARD_WIDGETS } from './workbench'
+import { getWorkCenterDashboard } from '@/api/workCenter'
 
 export default {
   name: 'home-index',
   data () {
     return {
-      layout: []
+      layout: [],
+      pendingCount: 0
     }
   },
   computed: {
@@ -58,6 +64,7 @@ export default {
   },
   mounted () {
     this.refreshLayout()
+    this.refreshWorkCenter()
     this.$parent.$on('$HANDLE_EDIT_MODULES', this.handleEdit)
   },
   beforeDestroy () {
@@ -67,6 +74,14 @@ export default {
     refreshLayout () {
       this.layout = loadWorkbenchLayout(this.username)
     },
+    async refreshWorkCenter () {
+      try {
+        const { data } = await getWorkCenterDashboard()
+        this.pendingCount = data?.pending_count || 0
+      } catch (error) {
+        this.pendingCount = 0
+      }
+    },
     handleEdit () {
       this.$router.push('/home/edit')
     }

+ 275 - 0
migrations/versions/20260802_430_unified_work_center.py

@@ -0,0 +1,275 @@
+"""Add the unified governance approval, task and notification work center."""
+
+from alembic import op
+
+
+revision = "20260802_430"
+down_revision = "20260801_420"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.governance_workflows (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','retired')
+            ),
+            current_version INTEGER NOT NULL CHECK (current_version > 0),
+            active_version_uid UUID,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.governance_workflow_versions (
+            uid UUID PRIMARY KEY,
+            workflow_uid UUID NOT NULL REFERENCES public.governance_workflows(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 (workflow_uid, version),
+            CHECK (jsonb_typeof(definition) = 'object')
+        );
+        ALTER TABLE public.governance_workflows
+            ADD CONSTRAINT governance_workflow_active_version_fk
+            FOREIGN KEY (active_version_uid)
+            REFERENCES public.governance_workflow_versions(uid);
+        CREATE UNIQUE INDEX uq_governance_workflow_published_version
+            ON public.governance_workflow_versions(workflow_uid)
+            WHERE status = 'published';
+
+        CREATE TABLE public.governance_tasks (
+            uid UUID PRIMARY KEY,
+            task_code VARCHAR(40) NOT NULL UNIQUE,
+            workflow_uid UUID NOT NULL REFERENCES public.governance_workflows(uid),
+            workflow_version INTEGER NOT NULL CHECK (workflow_version > 0),
+            task_type VARCHAR(40) NOT NULL CHECK (
+                task_type IN (
+                    'approval','quality_issue','semantic_governance',
+                    'data_product_approval','agent_approval',
+                    'governance_work_order','release','high_risk'
+                )
+            ),
+            subject_type VARCHAR(40) NOT NULL CHECK (
+                subject_type IN (
+                    'quality_issue','semantic_governance','data_product','agent'
+                )
+            ),
+            subject_uid VARCHAR(200) NOT NULL,
+            source_type VARCHAR(80) NOT NULL,
+            source_uid VARCHAR(200) NOT NULL,
+            title VARCHAR(300) NOT NULL,
+            description VARCHAR(2000) NOT NULL,
+            business_domain_uid VARCHAR(200),
+            priority VARCHAR(20) NOT NULL CHECK (
+                priority IN ('low','medium','high','critical')
+            ),
+            status VARCHAR(30) NOT NULL CHECK (
+                status IN (
+                    'pending','in_progress','pending_review','approved',
+                    'rejected','closed','reopened','cancelled'
+                )
+            ),
+            assignee_uid UUID NOT NULL REFERENCES public.users(id),
+            due_at TIMESTAMPTZ NOT NULL,
+            escalation_level INTEGER NOT NULL DEFAULT 0 CHECK (
+                escalation_level BETWEEN 0 AND 10
+            ),
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (
+                current_version > 0
+            ),
+            route_snapshot JSONB NOT NULL,
+            context JSONB NOT NULL DEFAULT '{}'::jsonb,
+            source_state_unchanged BOOLEAN NOT NULL DEFAULT TRUE CHECK (
+                source_state_unchanged
+            ),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_by UUID NOT NULL REFERENCES public.users(id),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            closed_at TIMESTAMPTZ,
+            CHECK (jsonb_typeof(route_snapshot) = 'object'),
+            CHECK (jsonb_typeof(context) = 'object')
+        );
+        CREATE UNIQUE INDEX uq_governance_task_active_source
+            ON public.governance_tasks(source_type, source_uid)
+            WHERE status NOT IN ('closed','cancelled');
+        CREATE INDEX idx_governance_task_worklist
+            ON public.governance_tasks(
+                assignee_uid, status, priority, due_at, created_at DESC
+            );
+        CREATE INDEX idx_governance_task_domain
+            ON public.governance_tasks(
+                business_domain_uid, status, due_at
+            );
+
+        CREATE TABLE public.governance_task_participants (
+            uid UUID PRIMARY KEY,
+            task_uid UUID NOT NULL
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            user_uid UUID NOT NULL REFERENCES public.users(id),
+            sequence INTEGER NOT NULL CHECK (sequence > 0),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('pending','decided','transferred')
+            ),
+            transferred_from_uid UUID REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (task_uid, user_uid)
+        );
+        CREATE INDEX idx_governance_task_participant_user
+            ON public.governance_task_participants(user_uid, status, task_uid);
+
+        CREATE TABLE public.governance_task_reviews (
+            uid UUID PRIMARY KEY,
+            task_uid UUID NOT NULL
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            reviewer_uid UUID NOT NULL REFERENCES public.users(id),
+            decision VARCHAR(20) NOT NULL CHECK (
+                decision IN ('approve','reject')
+            ),
+            reason VARCHAR(1000) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (task_uid, reviewer_uid)
+        );
+
+        CREATE TABLE public.governance_task_comments (
+            uid UUID PRIMARY KEY,
+            task_uid UUID NOT NULL
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            content VARCHAR(4000) NOT NULL,
+            mentions JSONB NOT NULL DEFAULT '[]'::jsonb,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(mentions) = 'array')
+        );
+
+        CREATE TABLE public.governance_task_attachments (
+            uid UUID PRIMARY KEY,
+            task_uid UUID NOT NULL
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            name VARCHAR(300) NOT NULL,
+            storage_ref VARCHAR(1000) NOT NULL,
+            content_hash CHAR(64) NOT NULL,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (task_uid, content_hash)
+        );
+
+        CREATE TABLE public.governance_task_events (
+            uid UUID PRIMARY KEY,
+            task_uid UUID NOT NULL
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            task_version INTEGER NOT NULL CHECK (task_version > 0),
+            action VARCHAR(40) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            before_state JSONB NOT NULL DEFAULT '{}'::jsonb,
+            after_state JSONB NOT NULL,
+            payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (task_uid, task_version, action),
+            CHECK (jsonb_typeof(before_state) = 'object'),
+            CHECK (jsonb_typeof(after_state) = 'object'),
+            CHECK (jsonb_typeof(payload) = 'object')
+        );
+        CREATE INDEX idx_governance_task_event_timeline
+            ON public.governance_task_events(task_uid, created_at, uid);
+
+        CREATE TABLE public.governance_notification_templates (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL,
+            channel VARCHAR(20) NOT NULL CHECK (
+                channel IN ('in_app','email')
+            ),
+            subject_template VARCHAR(500) NOT NULL,
+            body_template VARCHAR(4000) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('active','retired')
+            ),
+            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,
+            UNIQUE (code, channel)
+        );
+
+        CREATE TABLE public.governance_notification_preferences (
+            user_uid UUID PRIMARY KEY REFERENCES public.users(id),
+            enabled_channels JSONB NOT NULL DEFAULT '["in_app","email"]'::jsonb,
+            subscribed_events JSONB NOT NULL DEFAULT '[]'::jsonb,
+            quiet_hours JSONB NOT NULL DEFAULT '{}'::jsonb,
+            revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(enabled_channels) = 'array'),
+            CHECK (jsonb_typeof(subscribed_events) = 'array'),
+            CHECK (jsonb_typeof(quiet_hours) = 'object')
+        );
+
+        CREATE TABLE public.governance_notifications (
+            uid UUID PRIMARY KEY,
+            event_key VARCHAR(500) NOT NULL UNIQUE,
+            event_type VARCHAR(80) NOT NULL,
+            recipient_uid UUID NOT NULL REFERENCES public.users(id),
+            channel VARCHAR(20) NOT NULL CHECK (
+                channel IN ('in_app','email')
+            ),
+            subject VARCHAR(500) NOT NULL,
+            body VARCHAR(4000) NOT NULL,
+            related_task_uid UUID
+                REFERENCES public.governance_tasks(uid) ON DELETE CASCADE,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN (
+                    'pending','processing','delivered','suppressed','dead_letter'
+                )
+            ),
+            attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
+            max_attempts INTEGER NOT NULL DEFAULT 5 CHECK (max_attempts > 0),
+            next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            last_error VARCHAR(1000),
+            delivered_at TIMESTAMPTZ,
+            read_at TIMESTAMPTZ,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_governance_notification_inbox
+            ON public.governance_notifications(
+                recipient_uid, channel, read_at, created_at DESC
+            );
+        CREATE INDEX idx_governance_notification_delivery
+            ON public.governance_notifications(
+                channel, status, next_attempt_at
+            );
+
+        CREATE TABLE public.governance_notification_attempts (
+            uid UUID PRIMARY KEY,
+            notification_uid UUID NOT NULL
+                REFERENCES public.governance_notifications(uid) ON DELETE CASCADE,
+            attempt INTEGER NOT NULL CHECK (attempt > 0),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('pending','delivered','dead_letter')
+            ),
+            safe_error VARCHAR(1000),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (notification_uid, attempt)
+        );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "workflow, task and notification evidence is retained; "
+        "downgrade requires an approved archival migration"
+    )

+ 506 - 0
tests/core/governance/test_unified_work_center.py

@@ -0,0 +1,506 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+from app.core.governance.work_center import UnifiedWorkCenterService
+
+ACTOR = "01900000-0000-7000-8000-000000007001"
+REVIEWER_A = "01900000-0000-7000-8000-000000007002"
+REVIEWER_B = "01900000-0000-7000-8000-000000007003"
+REVIEWER_C = "01900000-0000-7000-8000-000000007004"
+NOW = datetime(2026, 8, 2, 9, 0, tzinfo=UTC)
+
+SUBJECT_TYPES = (
+    "quality_issue",
+    "semantic_governance",
+    "data_product",
+    "agent",
+)
+
+
+class MemoryWorkCenterRepository:
+    def __init__(self):
+        self.workflows = {}
+        self.workflow_versions = {}
+        self.tasks = {}
+        self.participants = {}
+        self.reviews = {}
+        self.events = {}
+        self.comments = {}
+        self.attachments = {}
+        self.notifications = {}
+        self.notification_attempts = []
+        self.templates = {}
+        self.audit = []
+        self.users = {ACTOR, REVIEWER_A, REVIEWER_B, REVIEWER_C}
+
+    def users_available(self, user_uids):
+        return set(user_uids) & self.users
+
+    def create_workflow(self, workflow, version):
+        self.workflows[workflow["uid"]] = deepcopy(workflow)
+        self.workflow_versions[version["uid"]] = deepcopy(version)
+        self.audit.append(("workflow_created", workflow["uid"]))
+        return deepcopy(workflow)
+
+    def get_workflow(self, uid):
+        value = self.workflows.get(uid)
+        return deepcopy(value) if value else None
+
+    def workflow_version(self, workflow_uid, version):
+        return next(
+            (
+                deepcopy(item)
+                for item in self.workflow_versions.values()
+                if item["workflow_uid"] == workflow_uid
+                and item["version"] == version
+            ),
+            None,
+        )
+
+    def publish_workflow(self, workflow, version, expected_version):
+        current = self.workflows[workflow["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("workflow version conflict")
+        self.workflows[workflow["uid"]] = deepcopy(workflow)
+        self.workflow_versions[version["uid"]] = deepcopy(version)
+        self.audit.append(("workflow_published", workflow["uid"]))
+        return deepcopy(workflow)
+
+    def list_workflows(self):
+        return [deepcopy(item) for item in self.workflows.values()]
+
+    def create_task(self, task, participants):
+        if any(
+            item["source_type"] == task["source_type"]
+            and item["source_uid"] == task["source_uid"]
+            and item["status"] not in {"closed", "cancelled"}
+            for item in self.tasks.values()
+        ):
+            return next(
+                deepcopy(item)
+                for item in self.tasks.values()
+                if item["source_type"] == task["source_type"]
+                and item["source_uid"] == task["source_uid"]
+                and item["status"] not in {"closed", "cancelled"}
+            )
+        self.tasks[task["uid"]] = deepcopy(task)
+        self.participants[task["uid"]] = deepcopy(participants)
+        self.events[task["uid"]] = [
+            {
+                "action": "created",
+                "actor_uid": task["created_by"],
+                "payload": {},
+                "created_at": task["created_at"],
+            }
+        ]
+        return deepcopy(task)
+
+    def get_task(self, uid):
+        value = self.tasks.get(uid)
+        return deepcopy(value) if value else None
+
+    def list_tasks(self, **filters):
+        records = list(self.tasks.values())
+        for key in ("status", "task_type", "subject_type", "assignee_uid"):
+            if filters.get(key):
+                records = [item for item in records if item.get(key) == filters[key]]
+        return [deepcopy(item) for item in records]
+
+    def update_task(self, task, expected_version, action, actor_uid, payload=None):
+        current = self.tasks[task["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("task version conflict")
+        updated = {**deepcopy(task), "current_version": expected_version + 1}
+        self.tasks[task["uid"]] = updated
+        self.events[task["uid"]].append(
+            {
+                "action": action,
+                "actor_uid": actor_uid,
+                "payload": deepcopy(payload or {}),
+                "created_at": updated["updated_at"],
+            }
+        )
+        return deepcopy(updated)
+
+    def participants_for_task(self, uid):
+        return deepcopy(self.participants.get(uid, []))
+
+    def replace_participant(self, task_uid, source_uid, target_uid, actor_uid):
+        for participant in self.participants[task_uid]:
+            if participant["user_uid"] == source_uid and participant["status"] == "pending":
+                participant.update(
+                    {
+                        "user_uid": target_uid,
+                        "transferred_from_uid": source_uid,
+                        "status": "pending",
+                    }
+                )
+                self.audit.append(("participant_transferred", task_uid, actor_uid))
+                return deepcopy(participant)
+        raise LookupError("pending participant was not found")
+
+    def add_review(self, review):
+        values = self.reviews.setdefault(review["task_uid"], [])
+        if any(item["reviewer_uid"] == review["reviewer_uid"] for item in values):
+            raise RuntimeError("reviewer already decided")
+        values.append(deepcopy(review))
+        for participant in self.participants[review["task_uid"]]:
+            if participant["user_uid"] == review["reviewer_uid"]:
+                participant["status"] = "decided"
+        return deepcopy(review)
+
+    def reviews_for_task(self, uid):
+        return deepcopy(self.reviews.get(uid, []))
+
+    def add_comment(self, record):
+        self.comments.setdefault(record["task_uid"], []).append(deepcopy(record))
+        return deepcopy(record)
+
+    def add_attachment(self, record):
+        self.attachments.setdefault(record["task_uid"], []).append(deepcopy(record))
+        return deepcopy(record)
+
+    def task_detail(self, uid):
+        return {
+            **self.get_task(uid),
+            "participants": self.participants_for_task(uid),
+            "reviews": self.reviews_for_task(uid),
+            "comments": deepcopy(self.comments.get(uid, [])),
+            "attachments": deepcopy(self.attachments.get(uid, [])),
+            "timeline": deepcopy(self.events.get(uid, [])),
+        }
+
+    def overdue_tasks(self, at):
+        return [
+            deepcopy(item)
+            for item in self.tasks.values()
+            if item["status"] in {"pending", "in_progress", "pending_review"}
+            and datetime.fromisoformat(item["due_at"]) <= at
+        ]
+
+    def create_notification(self, record):
+        self.notifications[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def list_notifications(self, recipient_uid, unread_only=False):
+        values = [
+            deepcopy(item)
+            for item in self.notifications.values()
+            if item["recipient_uid"] == recipient_uid
+        ]
+        if unread_only:
+            values = [item for item in values if item.get("read_at") is None]
+        return values
+
+    def claim_notifications(self, channel, at, limit):
+        records = [
+            item
+            for item in self.notifications.values()
+            if item["channel"] == channel
+            and item["status"] == "pending"
+            and datetime.fromisoformat(item["next_attempt_at"]) <= at
+        ][:limit]
+        for item in records:
+            item["status"] = "processing"
+        return [deepcopy(item) for item in records]
+
+    def record_delivery(self, notification, result, at):
+        current = self.notifications[notification["uid"]]
+        current.update(
+            {
+                "status": result.status,
+                "attempts": result.attempts,
+                "next_attempt_at": (
+                    result.available_at.isoformat()
+                    if result.available_at
+                    else current["next_attempt_at"]
+                ),
+                "last_error": result.last_error,
+                "delivered_at": at.isoformat() if result.status == "delivered" else None,
+            }
+        )
+        self.notification_attempts.append(
+            {
+                "notification_uid": notification["uid"],
+                "status": result.status,
+                "attempt": result.attempts,
+            }
+        )
+        return deepcopy(current)
+
+    def mark_notification_read(self, uid, recipient_uid, at):
+        item = self.notifications[uid]
+        if item["recipient_uid"] != recipient_uid:
+            raise LookupError("notification was not found")
+        item["read_at"] = at.isoformat()
+        return deepcopy(item)
+
+    def dashboard(self, at):
+        tasks = list(self.tasks.values())
+        closed = [item for item in tasks if item["status"] == "closed"]
+        overdue = [
+            item
+            for item in tasks
+            if item["status"] not in {"closed", "cancelled"}
+            and datetime.fromisoformat(item["due_at"]) <= at
+        ]
+        return {
+            "task_count": len(tasks),
+            "pending_count": sum(item["status"] == "pending" for item in tasks),
+            "overdue_count": len(overdue),
+            "closed_count": len(closed),
+            "closure_rate": len(closed) / len(tasks) if tasks else 0,
+            "by_type": {
+                kind: sum(item["subject_type"] == kind for item in tasks)
+                for kind in SUBJECT_TYPES
+            },
+            "trend": [],
+        }
+
+
+@pytest.fixture()
+def service():
+    repository = MemoryWorkCenterRepository()
+    uids = (
+        f"01900000-0000-7000-8000-{number:012d}"
+        for number in range(7100, 7900)
+    )
+    instance = UnifiedWorkCenterService(
+        repository,
+        uid_factory=lambda: next(uids),
+        now_factory=lambda: NOW,
+    )
+    return instance, repository
+
+
+def workflow_payload():
+    return {
+        "code": "GOVERNANCE_UNIFIED_APPROVAL",
+        "name": "统一治理审批",
+        "subject_types": list(SUBJECT_TYPES),
+        "routes": [
+            {
+                "priority": 10,
+                "conditions": [
+                    {"field": "risk_level", "operator": "eq", "value": "high"}
+                ],
+                "approval_mode": "dual_control",
+                "reviewer_uids": [REVIEWER_A, REVIEWER_B],
+                "min_approvals": 2,
+                "due_hours": 4,
+                "timeout_action": "escalate",
+                "notification_channels": ["in_app", "email"],
+            }
+        ],
+        "default_route": {
+            "approval_mode": "any",
+            "reviewer_uids": [REVIEWER_A, REVIEWER_B],
+            "min_approvals": 1,
+            "due_hours": 24,
+            "timeout_action": "close",
+            "notification_channels": ["in_app"],
+        },
+    }
+
+
+def publish_default(service):
+    center, _repository = service
+    workflow = center.create_workflow(workflow_payload(), actor_uid=ACTOR)
+    return center.publish_workflow(
+        workflow["uid"], expected_version=1, actor_uid=ACTOR
+    )
+
+
+def create_task(center, workflow, subject_type, source_uid, **context):
+    return center.create_task(
+        {
+            "workflow_uid": workflow["uid"],
+            "task_type": "approval",
+            "subject_type": subject_type,
+            "subject_uid": source_uid,
+            "source_type": subject_type,
+            "source_uid": source_uid,
+            "title": f"Review {subject_type}",
+            "description": "Unified task contract",
+            "priority": "high" if context.get("risk_level") == "high" else "medium",
+            "business_domain_uid": context.get("business_domain_uid"),
+            "context": context,
+        },
+        actor_uid=ACTOR,
+    )
+
+
+def test_four_business_types_share_one_task_contract_and_condition_routes(service):
+    center, repository = service
+    workflow = publish_default(service)
+
+    tasks = [
+        create_task(
+            center,
+            workflow,
+            subject_type,
+            f"{subject_type}-1",
+            risk_level="high" if subject_type == "agent" else "low",
+        )
+        for subject_type in SUBJECT_TYPES
+    ]
+
+    keys = set(tasks[0])
+    assert all(set(item) == keys for item in tasks)
+    assert {item["subject_type"] for item in tasks} == set(SUBJECT_TYPES)
+    assert tasks[-1]["route_snapshot"]["approval_mode"] == "dual_control"
+    assert tasks[0]["route_snapshot"]["approval_mode"] == "any"
+    assert all(item["source_state_unchanged"] is True for item in tasks)
+    assert len(repository.list_tasks()) == 4
+
+
+def test_any_dual_control_and_transfer_are_deterministic_and_audited(service):
+    center, repository = service
+    workflow = publish_default(service)
+    ordinary = create_task(center, workflow, "data_product", "product-1", risk_level="low")
+    approved = center.review_task(
+        ordinary["uid"],
+        {"decision": "approve", "reason": "ready"},
+        expected_version=1,
+        actor_uid=REVIEWER_A,
+    )
+    assert approved["status"] == "approved"
+
+    critical = create_task(center, workflow, "agent", "agent-1", risk_level="high")
+    first = center.review_task(
+        critical["uid"],
+        {"decision": "approve", "reason": "first review"},
+        expected_version=1,
+        actor_uid=REVIEWER_A,
+    )
+    assert first["status"] == "pending"
+    transferred = center.transfer_review(
+        critical["uid"],
+        {"source_user_uid": REVIEWER_B, "target_user_uid": REVIEWER_C, "reason": "shift"},
+        expected_version=2,
+        actor_uid=REVIEWER_B,
+    )
+    assert transferred["current_version"] == 3
+    final = center.review_task(
+        critical["uid"],
+        {"decision": "approve", "reason": "second independent review"},
+        expected_version=3,
+        actor_uid=REVIEWER_C,
+    )
+    assert final["status"] == "approved"
+    assert ("participant_transferred", critical["uid"], REVIEWER_B) in repository.audit
+
+
+def test_work_order_comments_attachments_timeline_close_and_reopen(service):
+    center, _repository = service
+    workflow = publish_default(service)
+    task = center.create_task(
+        {
+            "workflow_uid": workflow["uid"],
+            "task_type": "governance_work_order",
+            "subject_type": "quality_issue",
+            "subject_uid": "issue-1",
+            "source_type": "quality_issue",
+            "source_uid": "issue-1",
+            "title": "Repair quality issue",
+            "description": "Close the remediation loop",
+            "priority": "high",
+            "context": {"risk_level": "low"},
+        },
+        actor_uid=ACTOR,
+    )
+    center.add_comment(task["uid"], {"content": "Root cause confirmed", "mentions": [REVIEWER_A]}, actor_uid=ACTOR)
+    center.add_attachment(
+        task["uid"],
+        {"name": "evidence.json", "storage_ref": "minio://governance/evidence.json", "content_hash": "a" * 64},
+        actor_uid=ACTOR,
+    )
+    closed = center.close_task(
+        task["uid"],
+        {"resolution": "fixed", "evidence_refs": ["minio://governance/evidence.json"]},
+        expected_version=1,
+        actor_uid=REVIEWER_A,
+    )
+    reopened = center.reopen_task(
+        task["uid"],
+        {"reason": "recurrence detected"},
+        expected_version=2,
+        actor_uid=REVIEWER_A,
+    )
+    detail = center.task_detail(task["uid"])
+
+    assert closed["status"] == "closed"
+    assert reopened["status"] == "reopened"
+    assert len(detail["comments"]) == 1
+    assert len(detail["attachments"]) == 1
+    assert [item["action"] for item in detail["timeline"]][-2:] == ["closed", "reopened"]
+
+
+def test_timeout_escalation_and_auto_close_are_versioned(service):
+    center, repository = service
+    workflow = publish_default(service)
+    escalated_task = create_task(center, workflow, "agent", "agent-2", risk_level="high")
+    closed_task = create_task(center, workflow, "quality_issue", "issue-2", risk_level="low")
+
+    results = center.process_timeouts(at=NOW + timedelta(hours=25), actor_uid=ACTOR)
+
+    assert {item["uid"] for item in results} == {escalated_task["uid"], closed_task["uid"]}
+    assert repository.get_task(escalated_task["uid"])["escalation_level"] == 1
+    assert repository.get_task(closed_task["uid"])["status"] == "closed"
+    assert repository.events[closed_task["uid"]][-1]["action"] == "timeout_closed"
+
+
+def test_email_failure_retries_delivery_and_in_app_read_are_audited(service):
+    center, repository = service
+    workflow = publish_default(service)
+    create_task(center, workflow, "agent", "agent-3", risk_level="high")
+    email = next(item for item in repository.notifications.values() if item["channel"] == "email")
+    in_app = next(item for item in repository.notifications.values() if item["channel"] == "in_app")
+    attempts = {"count": 0}
+
+    def flaky_email(_notification):
+        attempts["count"] += 1
+        if attempts["count"] == 1:
+            raise RuntimeError("SMTP secret-token=do-not-log")
+
+    first = center.deliver_notifications("email", flaky_email, at=NOW)
+    second = center.deliver_notifications(
+        "email", flaky_email, at=NOW + timedelta(minutes=1)
+    )
+    read = center.mark_notification_read(in_app["uid"], actor_uid=in_app["recipient_uid"])
+
+    assert first[0]["status"] == "pending"
+    assert "do-not-log" not in first[0]["last_error"]
+    assert second[0]["status"] == "delivered"
+    assert repository.notifications[email["uid"]]["attempts"] == 2
+    assert read["read_at"] is not None
+    assert sum(
+        item["notification_uid"] == email["uid"]
+        for item in repository.notification_attempts
+    ) == 2
+
+
+def test_dashboard_reports_pending_overdue_closure_and_type_coverage(service):
+    center, _repository = service
+    workflow = publish_default(service)
+    for subject_type in SUBJECT_TYPES:
+        create_task(center, workflow, subject_type, f"dash-{subject_type}", risk_level="low")
+    first = center.list_tasks()[0]
+    center.close_task(
+        first["uid"],
+        {"resolution": "done", "evidence_refs": ["evidence-1"]},
+        expected_version=1,
+        actor_uid=REVIEWER_A,
+    )
+
+    result = center.dashboard(at=NOW + timedelta(hours=25))
+
+    assert result["task_count"] == 4
+    assert result["closed_count"] == 1
+    assert result["overdue_count"] == 3
+    assert result["closure_rate"] == 0.25
+    assert all(result["by_type"][kind] == 1 for kind in SUBJECT_TYPES)

+ 274 - 0
tests/integration/test_unified_work_center_postgres.py

@@ -0,0 +1,274 @@
+from __future__ import annotations
+
+import os
+import uuid
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import Session
+
+from app.core.governance.work_center import UnifiedWorkCenterService
+from app.core.governance.work_center_repository import SqlAlchemyWorkCenterRepository
+
+pytestmark = pytest.mark.integration
+
+
+@pytest.fixture()
+def postgres_center():
+    database_url = os.environ.get("TEST_DATABASE_URL")
+    if not database_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+    engine = create_engine(database_url)
+    session = Session(engine)
+    prefix = uuid.uuid4().hex[:10]
+    users = [str(uuid.uuid4()) for _ in range(4)]
+    for index, user_uid in enumerate(users):
+        session.execute(
+            text(
+                """
+                INSERT INTO public.users (
+                    id, username, display_name, password_hash, status
+                ) VALUES (
+                    CAST(:uid AS uuid), :username, :display_name, 'not-a-login', 'active'
+                )
+                """
+            ),
+            {
+                "uid": user_uid,
+                "username": f"wp07-{prefix}-{index}@example.test",
+                "display_name": f"WP07 {index}",
+            },
+        )
+    session.commit()
+    service = UnifiedWorkCenterService(
+        SqlAlchemyWorkCenterRepository(session),
+        commit=session.commit,
+        rollback=session.rollback,
+    )
+    yield service, session, users, prefix
+    session.rollback()
+    session.execute(
+        text(
+            "DELETE FROM public.outbox_events WHERE aggregate_type = 'governance_task' "
+            "AND aggregate_id IN (SELECT uid::text FROM public.governance_tasks WHERE source_uid LIKE :prefix)"
+        ),
+        {"prefix": f"{prefix}-%"},
+    )
+    workflow_uids = [
+        row[0]
+        for row in session.execute(
+            text("SELECT uid FROM public.governance_workflows WHERE code = :code"),
+            {"code": f"WP07_{prefix.upper()}"},
+        )
+    ]
+    session.execute(
+        text(
+            "DELETE FROM public.governance_notifications WHERE related_task_uid IN "
+            "(SELECT uid FROM public.governance_tasks WHERE source_uid LIKE :prefix)"
+        ),
+        {"prefix": f"{prefix}-%"},
+    )
+    session.execute(
+        text("DELETE FROM public.governance_tasks WHERE source_uid LIKE :prefix"),
+        {"prefix": f"{prefix}-%"},
+    )
+    if workflow_uids:
+        session.execute(
+            text(
+                "UPDATE public.governance_workflows SET active_version_uid = NULL "
+                "WHERE uid = ANY(CAST(:uids AS uuid[]))"
+            ),
+            {"uids": [str(value) for value in workflow_uids]},
+        )
+        session.execute(
+            text(
+                "DELETE FROM public.governance_workflow_versions "
+                "WHERE workflow_uid = ANY(CAST(:uids AS uuid[]))"
+            ),
+            {"uids": [str(value) for value in workflow_uids]},
+        )
+        session.execute(
+            text(
+                "DELETE FROM public.governance_workflows "
+                "WHERE uid = ANY(CAST(:uids AS uuid[]))"
+            ),
+            {"uids": [str(value) for value in workflow_uids]},
+        )
+    session.execute(
+        text(
+            "DELETE FROM public.governance_notification_preferences "
+            "WHERE user_uid = ANY(CAST(:uids AS uuid[]))"
+        ),
+        {"uids": users},
+    )
+    session.execute(
+        text(
+            "DELETE FROM public.governance_notification_templates "
+            "WHERE created_by = ANY(CAST(:uids AS uuid[]))"
+        ),
+        {"uids": users},
+    )
+    session.execute(
+        text("DELETE FROM public.users WHERE id = ANY(CAST(:uids AS uuid[]))"),
+        {"uids": users},
+    )
+    session.commit()
+    session.close()
+    engine.dispose()
+
+
+def test_four_sources_share_persisted_contract_audit_and_retry(postgres_center):
+    center, session, users, prefix = postgres_center
+    actor, reviewer_a, reviewer_b, _reviewer_c = users
+    workflow = center.create_workflow(
+        {
+            "code": f"WP07_{prefix.upper()}",
+            "name": "WP07 PostgreSQL contract",
+            "subject_types": [
+                "quality_issue",
+                "semantic_governance",
+                "data_product",
+                "agent",
+            ],
+            "routes": [
+                {
+                    "priority": 1,
+                    "conditions": [
+                        {"field": "risk_level", "operator": "eq", "value": "high"}
+                    ],
+                    "approval_mode": "dual_control",
+                    "reviewer_uids": [reviewer_a, reviewer_b],
+                    "min_approvals": 2,
+                    "due_hours": 1,
+                    "timeout_action": "escalate",
+                    "notification_channels": ["in_app", "email"],
+                }
+            ],
+            "default_route": {
+                "approval_mode": "any",
+                "reviewer_uids": [reviewer_a, reviewer_b],
+                "min_approvals": 1,
+                "due_hours": 24,
+                "timeout_action": "close",
+                "notification_channels": ["in_app"],
+            },
+        },
+        actor_uid=actor,
+    )
+    workflow = center.publish_workflow(
+        workflow["uid"], expected_version=1, actor_uid=actor
+    )
+    template = center.create_notification_template(
+        {
+            "code": "task_created",
+            "channel": "email",
+            "subject_template": "[DataOps] {{task_code}} {{title}}",
+            "body_template": "{{description}}",
+        },
+        actor_uid=actor,
+    )
+    center.revise_notification_template(
+        template["uid"],
+        {
+            "subject_template": "[DataOps] {{task_code}} {{title}}",
+            "body_template": "待办:{{description}}",
+            "status": "active",
+        },
+        expected_version=1,
+        actor_uid=actor,
+    )
+    preference = center.replace_notification_preferences(
+        {
+            "enabled_channels": ["in_app"],
+            "subscribed_events": ["task_created"],
+            "quiet_hours": {},
+        },
+        expected_revision=0,
+        actor_uid=reviewer_b,
+    )
+    assert preference["revision"] == 1
+    tasks = []
+    for subject_type in (
+        "quality_issue",
+        "semantic_governance",
+        "data_product",
+        "agent",
+    ):
+        tasks.append(
+            center.create_task(
+                {
+                    "workflow_uid": workflow["uid"],
+                    "task_type": "approval",
+                    "subject_type": subject_type,
+                    "subject_uid": f"{prefix}-{subject_type}",
+                    "source_type": subject_type,
+                    "source_uid": f"{prefix}-{subject_type}",
+                    "title": f"Review {subject_type}",
+                    "description": "PostgreSQL integration evidence",
+                    "priority": "high" if subject_type == "agent" else "medium",
+                    "context": {"risk_level": "high" if subject_type == "agent" else "low"},
+                },
+                actor_uid=actor,
+            )
+        )
+
+    approved = center.review_task(
+        tasks[0]["uid"],
+        {"decision": "approve", "reason": "verified"},
+        expected_version=1,
+        actor_uid=reviewer_a,
+    )
+    center.close_task(
+        approved["uid"],
+        {"resolution": "closed with evidence", "evidence_refs": ["evidence://wp07"]},
+        expected_version=2,
+        actor_uid=reviewer_a,
+    )
+    email_attempts = center.deliver_notifications(
+        "email",
+        lambda _notification: (_ for _ in ()).throw(RuntimeError("SMTP unavailable")),
+        at=datetime.now(UTC) + timedelta(minutes=1),
+    )
+    dashboard = center.dashboard(at=datetime.now(UTC) + timedelta(days=2))
+
+    assert {item["subject_type"] for item in tasks} == {
+        "quality_issue",
+        "semantic_governance",
+        "data_product",
+        "agent",
+    }
+    assert all(item["source_state_unchanged"] for item in tasks)
+    assert email_attempts[0]["status"] == "pending"
+    assert email_attempts[0]["attempts"] == 1
+    assert email_attempts[0]["subject"].startswith("[DataOps] GWT-")
+    suppressed = session.execute(
+        text(
+            "SELECT COUNT(*) FROM public.governance_notifications "
+            "WHERE recipient_uid = CAST(:uid AS uuid) AND channel = 'email' "
+            "AND status = 'suppressed'"
+        ),
+        {"uid": reviewer_b},
+    ).scalar_one()
+    assert suppressed == 1
+    assert dashboard["by_type"]["quality_issue"] >= 1
+    assert dashboard["closed_count"] >= 1
+    actions = {
+        row[0]
+        for row in session.execute(
+            text(
+                "SELECT action FROM public.governance_task_events "
+                "WHERE task_uid = CAST(:uid AS uuid)"
+            ),
+            {"uid": tasks[0]["uid"]},
+        )
+    }
+    assert {"created", "reviewed", "closed"} <= actions
+    outbox = session.execute(
+        text(
+            "SELECT COUNT(*) FROM public.outbox_events "
+            "WHERE aggregate_type = 'governance_task' AND aggregate_id = :uid"
+        ),
+        {"uid": tasks[0]["uid"]},
+    ).scalar_one()
+    assert outbox == 2

+ 126 - 0
tests/test_work_center_api.py

@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+USER_UID = "01900000-0000-7000-8000-000000007901"
+TASK_UID = "01900000-0000-7000-8000-000000007902"
+WORKFLOW_UID = "01900000-0000-7000-8000-000000007903"
+
+
+class FakeWorkCenterService:
+    def __init__(self):
+        self.calls = []
+
+    def list_tasks(self, **filters):
+        self.calls.append(("list_tasks", filters))
+        return [{"uid": TASK_UID, "subject_type": "quality_issue"}]
+
+    def create_task(self, payload, actor_uid):
+        self.calls.append(("create_task", payload, actor_uid))
+        return {"uid": TASK_UID, "current_version": 1, **payload}
+
+    def review_task(self, uid, payload, expected_version, actor_uid):
+        self.calls.append(("review", uid, payload, expected_version, actor_uid))
+        return {"uid": uid, "status": "approved", "current_version": 2}
+
+    def list_workflows(self):
+        return []
+
+    def create_workflow(self, payload, actor_uid):
+        self.calls.append(("workflow", payload, actor_uid))
+        return {"uid": WORKFLOW_UID, "current_version": 1}
+
+    def dashboard(self, at=None):
+        return {"pending_count": 1, "overdue_count": 0, "closure_rate": 0}
+
+    def get_notification_preferences(self, user_uid):
+        return {"user_uid": user_uid, "revision": 0, "enabled_channels": ["in_app"]}
+
+    def replace_notification_preferences(self, payload, expected_revision, actor_uid):
+        self.calls.append(("preferences", payload, expected_revision, actor_uid))
+        return {"user_uid": actor_uid, "revision": expected_revision + 1, **payload}
+
+
+def _headers(role, **extra):
+    return {"Authorization": f"Bearer {role}", **extra}
+
+
+def _client(monkeypatch):
+    from app import create_app
+    from app.api.system import work_center
+
+    service = FakeWorkCenterService()
+    monkeypatch.setattr(work_center, "_service", lambda: service)
+    monkeypatch.setattr(
+        "app.core.system.auth.load_identity_from_token",
+        lambda token, secret: (
+            {"id": USER_UID, "username": token, "roles": [token]}
+            if token in {"viewer", "editor", "admin"}
+            else None
+        ),
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    return app.test_client(), service
+
+
+def test_unified_inbox_is_readable_and_filters_non_manager_scope(monkeypatch):
+    client, service = _client(monkeypatch)
+    response = client.get(
+        "/api/system/work-center/tasks?subject_type=quality_issue",
+        headers=_headers("viewer"),
+    )
+    assert response.status_code == 200
+    filters = service.calls[-1][1]
+    assert filters["requester_uid"] == USER_UID
+    assert filters["can_manage"] is False
+    assert filters["subject_type"] == "quality_issue"
+
+
+def test_editor_can_create_and_review_versioned_task_but_not_manage_flow(monkeypatch):
+    client, service = _client(monkeypatch)
+    created = client.post(
+        "/api/system/work-center/tasks",
+        json={"title": "quality remediation"},
+        headers=_headers("editor"),
+    )
+    assert created.status_code == 201
+    assert created.headers["ETag"] == '"1"'
+
+    missing_version = client.post(
+        f"/api/system/work-center/tasks/{TASK_UID}/review",
+        json={"decision": "approve", "reason": "ready"},
+        headers=_headers("editor"),
+    )
+    assert missing_version.status_code == 428
+    reviewed = client.post(
+        f"/api/system/work-center/tasks/{TASK_UID}/review",
+        json={"decision": "approve", "reason": "ready"},
+        headers=_headers("editor", **{"If-Match": '"1"'}),
+    )
+    assert reviewed.status_code == 200
+    assert reviewed.headers["ETag"] == '"2"'
+    assert service.calls[-1][3] == 1
+
+    forbidden = client.post(
+        "/api/system/work-center/workflows",
+        json={"code": "FLOW"},
+        headers=_headers("editor"),
+    )
+    assert forbidden.status_code == 403
+
+
+def test_admin_manages_workflow_and_user_versions_preferences(monkeypatch):
+    client, service = _client(monkeypatch)
+    workflow = client.post(
+        "/api/system/work-center/workflows",
+        json={"code": "FLOW"},
+        headers=_headers("admin"),
+    )
+    assert workflow.status_code == 201
+    preference = client.put(
+        "/api/system/work-center/preferences",
+        json={"enabled_channels": ["in_app"]},
+        headers=_headers("editor", **{"If-Match": '"0"'}),
+    )
+    assert preference.status_code == 200
+    assert preference.headers["ETag"] == '"1"'
+    assert service.calls[-1][2] == 0

+ 98 - 0
tests/test_work_center_contract.py

@@ -0,0 +1,98 @@
+from pathlib import Path
+
+from app.core.system.permissions import (
+    WORK_CENTER_MANAGE,
+    WORK_CENTER_OPERATE,
+    WORK_CENTER_READ,
+    permission_for_request,
+    permissions_for_roles,
+)
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_work_center_migration_is_versioned_audited_and_non_destructive():
+    migration = (
+        ROOT / "migrations/versions/20260802_430_unified_work_center.py"
+    ).read_text(encoding="utf-8")
+    assert 'revision = "20260802_430"' in migration
+    assert 'down_revision = "20260801_420"' in migration
+    for table in (
+        "governance_workflows",
+        "governance_workflow_versions",
+        "governance_tasks",
+        "governance_task_participants",
+        "governance_task_reviews",
+        "governance_task_comments",
+        "governance_task_attachments",
+        "governance_task_events",
+        "governance_notification_templates",
+        "governance_notification_preferences",
+        "governance_notifications",
+        "governance_notification_attempts",
+    ):
+        assert f"CREATE TABLE public.{table}" in migration
+    assert "uq_governance_task_active_source" in migration
+    assert "source_state_unchanged" in migration
+    assert "raise RuntimeError" in migration
+    assert "DROP TABLE" not in migration.upper()
+
+
+def test_work_center_permissions_separate_read_operate_and_manage():
+    assert WORK_CENTER_READ in permissions_for_roles(["viewer"])
+    assert WORK_CENTER_OPERATE not in permissions_for_roles(["viewer"])
+    assert WORK_CENTER_OPERATE in permissions_for_roles(["editor"])
+    assert WORK_CENTER_MANAGE not in permissions_for_roles(["editor"])
+    assert WORK_CENTER_MANAGE in permissions_for_roles(["admin"])
+    assert permission_for_request("/api/system/work-center/tasks", "GET") == (
+        WORK_CENTER_READ,
+    )
+    assert permission_for_request("/api/system/work-center/tasks/x/review", "POST") == (
+        WORK_CENTER_OPERATE,
+    )
+    assert permission_for_request("/api/system/work-center/workflows", "POST") == (
+        WORK_CENTER_MANAGE,
+    )
+
+
+def test_work_center_api_and_frontend_expose_the_complete_operator_surface():
+    api = (ROOT / "app/api/system/work_center.py").read_text(encoding="utf-8")
+    client = (ROOT / "frontend/src/api/workCenter.js").read_text(encoding="utf-8")
+    view = (
+        ROOT / "frontend/src/views/dataReview/workCenter.vue"
+    ).read_text(encoding="utf-8")
+    routes = (ROOT / "frontend/src/router/routes.js").read_text(encoding="utf-8")
+    home = (ROOT / "frontend/src/views/home/index.vue").read_text(encoding="utf-8")
+
+    for path in (
+        "/work-center/workflows",
+        "/work-center/tasks",
+        "/comments",
+        "/attachments",
+        "/work-center/notifications",
+        "/work-center/templates",
+        "/work-center/preferences",
+        "/work-center/dashboard",
+    ):
+        assert path in api
+    for operation in (
+        "listWorkCenterTasks",
+        "getWorkCenterTask",
+        "reviewWorkCenterTask",
+        "transferWorkCenterTask",
+        "closeWorkCenterTask",
+        "reopenWorkCenterTask",
+        "addWorkCenterComment",
+        "addWorkCenterAttachment",
+        "listWorkCenterNotifications",
+        "getWorkCenterDashboard",
+    ):
+        assert operation in client
+    assert "统一工作中心" in view
+    assert "质量问题" in view
+    assert "术语标准" in view
+    assert "数据产品" in view
+    assert "Agent 审批" in view
+    assert "逾期待办" in view
+    assert "/dataReview/work-center" in routes
+    assert "getWorkCenterDashboard" in home