瀏覽代碼

feat: add data observability incident operations

马小龙 3 月之前
父節點
當前提交
b6289e4c11
共有 26 個文件被更改,包括 5992 次插入 和 7 次删除
  1. 1 0
      app/api/data_factory/__init__.py
  2. 173 0
      app/api/data_factory/observability_routes.py
  3. 818 0
      app/core/events/data_observability.py
  4. 759 0
      app/core/events/data_observability_repository.py
  5. 15 0
      app/core/system/permissions.py
  6. 1 0
      deployment/app/api/data_factory/__init__.py
  7. 173 0
      deployment/app/api/data_factory/observability_routes.py
  8. 818 0
      deployment/app/core/events/data_observability.py
  9. 759 0
      deployment/app/core/events/data_observability_repository.py
  10. 15 0
      deployment/app/core/system/permissions.py
  11. 183 0
      deployment/migrations/versions/20260731_410_data_observability.py
  12. 11 5
      docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md
  13. 23 0
      docs/architecture/DATA_MODEL.md
  14. 281 1
      docs/architecture/OPENAPI.yaml
  15. 95 0
      docs/phase2/P2_WP05_DATA_OBSERVABILITY_INCIDENTS.md
  16. 27 0
      frontend/src/api/dataObservability.js
  17. 45 1
      frontend/src/router/routes.js
  18. 476 0
      frontend/src/views/dataFactory/observability/index.vue
  19. 32 0
      frontend/src/views/dataFactory/observability/model.js
  20. 183 0
      migrations/versions/20260731_410_data_observability.py
  21. 378 0
      tests/core/orchestration/test_data_observability.py
  22. 190 0
      tests/core/orchestration/test_data_observability_api.py
  23. 59 0
      tests/core/orchestration/test_data_observability_frontend_contract.py
  24. 30 0
      tests/core/orchestration/test_data_observability_migration_contract.py
  25. 431 0
      tests/integration/test_data_observability_postgres.py
  26. 16 0
      tests/test_permission_matrix.py

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

@@ -3,3 +3,4 @@ from flask import Blueprint
 bp = Blueprint("data_factory", __name__)
 
 from app.api.data_factory import routes  # noqa: E402, F401
+from app.api.data_factory import observability_routes  # noqa: E402, F401

+ 173 - 0
app/api/data_factory/observability_routes.py

@@ -0,0 +1,173 @@
+"""HTTP boundary for SLO monitoring and evidence-bound data incidents."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.data_factory import bp
+from app.models.result import failed, success
+
+
+def get_data_observability_service():
+    from app.core.events.data_observability import DataObservabilityService
+    from app.core.events.data_observability_repository import (
+        SqlAlchemyDataObservabilityRepository,
+    )
+
+    return DataObservabilityService(
+        SqlAlchemyDataObservabilityRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _actor_uid():
+    identity = getattr(g, "current_user", {}) or {}
+    return identity.get("id") or identity.get("sub")
+
+
+def _payload():
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict):
+        raise ValueError("request body must be an object")
+    return value
+
+
+def _error(error):
+    db.session.rollback()
+    if isinstance(error, LookupError):
+        return jsonify(failed(str(error), code=404)), 404
+    if isinstance(error, ValueError):
+        return jsonify(failed(str(error), code=400)), 400
+    if isinstance(error, RuntimeError):
+        return jsonify(failed(str(error), code=409)), 409
+    return jsonify(failed("数据可观测与事故请求处理失败", code=500)), 500
+
+
+@bp.get("/observability/overview")
+def get_observability_overview():
+    try:
+        return jsonify(success(get_data_observability_service().overview())), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/observability/slos")
+def list_slo_policies():
+    try:
+        return jsonify(success(get_data_observability_service().list_slos())), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/slos")
+def create_slo_policy():
+    try:
+        record = get_data_observability_service().create_slo(
+            _payload(), actor_uid=_actor_uid()
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/collect")
+def collect_observability_signals():
+    try:
+        _payload()
+        record = get_data_observability_service().collect(
+            actor_uid=_actor_uid()
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/observability/alerts")
+def list_observability_alerts():
+    try:
+        return jsonify(
+            success(get_data_observability_service().list_alerts())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/alerts/<alert_uid>/suppress")
+def suppress_observability_alert(alert_uid):
+    try:
+        payload = _payload()
+        record = get_data_observability_service().suppress_alert(
+            alert_uid,
+            until=payload.get("until"),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/alerts/<alert_uid>/acknowledge-delivery")
+def acknowledge_observability_alert_delivery(alert_uid):
+    try:
+        payload = _payload()
+        record = get_data_observability_service().acknowledge_delivery(
+            alert_uid,
+            channel=payload.get("channel"),
+            receipt_id=payload.get("receipt_id"),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/observability/incidents")
+def list_data_incidents():
+    try:
+        return jsonify(
+            success(get_data_observability_service().list_incidents())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/observability/incidents/<incident_uid>")
+def get_data_incident(incident_uid):
+    try:
+        return jsonify(
+            success(
+                get_data_observability_service().incident_detail(incident_uid)
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/incidents/<incident_uid>/escalate")
+def escalate_data_incident(incident_uid):
+    try:
+        payload = _payload()
+        record = get_data_observability_service().escalate_incident(
+            incident_uid,
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/incidents/<incident_uid>/close")
+def close_data_incident(incident_uid):
+    try:
+        record = get_data_observability_service().close_incident(
+            incident_uid,
+            _payload(),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)

+ 818 - 0
app/core/events/data_observability.py

@@ -0,0 +1,818 @@
+"""Cross-domain data observability, alert aggregation and incident operations."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+import re
+import uuid
+from collections.abc import Callable
+from datetime import datetime
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+
+SLI_CATALOG = (
+    {
+        "type": "freshness",
+        "name": "新鲜度",
+        "unit": "seconds",
+        "description": "源数据观测时间到当前时间的延迟",
+    },
+    {
+        "type": "completeness",
+        "name": "完整性",
+        "unit": "ratio",
+        "description": "约定字段和记录满足完整性要求的比例",
+    },
+    {
+        "type": "quality",
+        "name": "质量",
+        "unit": "score",
+        "description": "确定性质量规则计算得到的质量分数",
+    },
+    {
+        "type": "delivery",
+        "name": "交付",
+        "unit": "ratio",
+        "description": "约定窗口内成功完成采集或交付的比例",
+    },
+)
+SLI_TYPES = frozenset(item["type"] for item in SLI_CATALOG)
+LAYERS = ("service", "task", "data", "capacity")
+SEVERITIES = ("info", "warning", "error", "critical")
+IMPACT_TYPES = frozenset(
+    {"asset", "data_product", "business_domain", "user_group", "service"}
+)
+OPERATORS = frozenset({">=", "<="})
+SECRET_TOKENS = frozenset(
+    {
+        "apikey",
+        "authorization",
+        "connectionstring",
+        "credential",
+        "dsn",
+        "password",
+        "secret",
+        "token",
+    }
+)
+CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,119}$")
+
+
+def _normalized_key(value: Any) -> str:
+    return re.sub(r"[^a-z0-9]", "", str(value).casefold())
+
+
+def _reject_secret_material(value: Any, path: str = "$") -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            normalized = _normalized_key(key)
+            if any(token in normalized for token in SECRET_TOKENS):
+                raise ValueError(f"secret material is not allowed at {path}.{key}")
+            _reject_secret_material(item, f"{path}.{key}")
+    elif isinstance(value, list):
+        for index, item in enumerate(value):
+            _reject_secret_material(item, f"{path}[{index}]")
+
+
+def _closed_object(
+    value: Any,
+    allowed: set[str] | frozenset[str],
+    label: str,
+) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise ValueError(f"{label} must be an object")
+    _reject_secret_material(value)
+    unknown = sorted(set(value) - set(allowed))
+    if unknown:
+        raise ValueError(
+            f"{label} contains unsupported fields: {', '.join(unknown)}"
+        )
+    return dict(value)
+
+
+def _string(value: Any, label: str, maximum: int = 500) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    result = value.strip()
+    if len(result) > maximum:
+        raise ValueError(f"{label} exceeds {maximum} characters")
+    return result
+
+
+def _uid(value: Any, label: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (TypeError, ValueError, AttributeError) as error:
+        raise ValueError(f"{label} must be a UUID") from error
+
+
+def _number(value: Any, label: str) -> float:
+    if isinstance(value, bool):
+        raise ValueError(f"{label} must be numeric")
+    try:
+        result = float(value)
+    except (TypeError, ValueError) as error:
+        raise ValueError(f"{label} must be numeric") from error
+    if not math.isfinite(result):
+        raise ValueError(f"{label} must be finite")
+    return result
+
+
+def _timestamp(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 _canonical_hash(value: Any) -> str:
+    serialized = json.dumps(
+        value,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    )
+    return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+
+
+def _severity_rank(value: str) -> int:
+    try:
+        return SEVERITIES.index(value)
+    except ValueError as error:
+        raise ValueError("severity is invalid") from error
+
+
+class DataObservabilityService:
+    """Operate deterministic SLO evidence without inventing root causes."""
+
+    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
+
+    @staticmethod
+    def sli_catalog() -> list[dict[str, str]]:
+        return [dict(item) for item in SLI_CATALOG]
+
+    def create_slo(self, payload: Any, *, actor_uid: str) -> dict[str, Any]:
+        body = _closed_object(
+            payload,
+            {
+                "code",
+                "name",
+                "sli_type",
+                "scope_type",
+                "scope_uid",
+                "operator",
+                "target",
+                "window_seconds",
+                "owner_uid",
+            },
+            "SLO policy",
+        )
+        code = _string(body.get("code"), "code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("SLO policy code is invalid")
+        sli_type = _string(body.get("sli_type"), "sli_type", 30)
+        if sli_type not in SLI_TYPES:
+            raise ValueError("SLO policy sli_type is invalid")
+        operator = _string(body.get("operator"), "operator", 2)
+        if operator not in OPERATORS:
+            raise ValueError("SLO policy operator is invalid")
+        target = _number(body.get("target"), "target")
+        window_seconds = int(_number(body.get("window_seconds"), "window_seconds"))
+        if window_seconds < 60 or window_seconds > 31_536_000:
+            raise ValueError("window_seconds must be between 60 and 31536000")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "code": code,
+            "name": _string(body.get("name"), "name", 300),
+            "sli_type": sli_type,
+            "scope_type": _string(body.get("scope_type"), "scope_type", 40),
+            "scope_uid": _string(body.get("scope_uid"), "scope_uid", 500),
+            "operator": operator,
+            "target": target,
+            "window_seconds": window_seconds,
+            "owner_uid": _uid(body.get("owner_uid"), "owner_uid"),
+            "status": "active",
+            "created_by": _uid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "updated_at": now,
+        }
+        try:
+            stored = self.repository.create_slo(record)
+            self.commit()
+            return stored
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_slos(self) -> list[dict[str, Any]]:
+        return self.repository.list_slos()
+
+    def collect(self, *, actor_uid: str) -> dict[str, Any]:
+        actor = _uid(actor_uid, "actor_uid")
+        counters = {
+            "processed": 0,
+            "ignored": 0,
+            "created_alerts": 0,
+            "aggregated_alerts": 0,
+            "recovered_alerts": 0,
+        }
+        incident_uids: list[str] = []
+        try:
+            for raw_signal in self.repository.pending_signals():
+                signal = self._signal(raw_signal)
+                if self.repository.source_event_exists(
+                    signal["source_event_key"]
+                ):
+                    counters["ignored"] += 1
+                    continue
+                claimed = self.repository.record_source_event(
+                    {
+                        "uid": self.uid_factory(),
+                        "source_event_key": signal["source_event_key"],
+                        "source_type": signal["source_type"],
+                        "source_uid": signal["source_uid"],
+                        "status": signal["status"],
+                        "signal": signal,
+                        "processed_at": self.now_factory().isoformat(),
+                    }
+                )
+                if claimed is False:
+                    counters["ignored"] += 1
+                    continue
+                result = self._process_signal(signal, actor_uid=actor)
+                counters["processed"] += 1
+                counters[result["counter"]] += 1
+                if (
+                    result.get("incident_uid")
+                    and result["incident_uid"] not in incident_uids
+                ):
+                    incident_uids.append(result["incident_uid"])
+            self.commit()
+        except Exception:
+            self.rollback()
+            raise
+        return {**counters, "incident_uids": incident_uids}
+
+    def _signal(self, value: Any) -> dict[str, Any]:
+        body = _closed_object(
+            value,
+            {
+                "source_event_key",
+                "source_type",
+                "source_uid",
+                "correlation_key",
+                "layer",
+                "sli_type",
+                "status",
+                "severity",
+                "title",
+                "owner_uid",
+                "observed_at",
+                "actual",
+                "target",
+                "evidence",
+                "impacts",
+            },
+            "observability signal",
+        )
+        layer = _string(body.get("layer"), "layer", 20)
+        if layer not in LAYERS:
+            raise ValueError("observability signal layer is invalid")
+        sli_type = _string(body.get("sli_type"), "sli_type", 30)
+        if sli_type not in SLI_TYPES:
+            raise ValueError("observability signal sli_type is invalid")
+        status = _string(body.get("status"), "status", 20)
+        if status not in {"violated", "recovered"}:
+            raise ValueError("observability signal status is invalid")
+        severity = _string(body.get("severity"), "severity", 20)
+        _severity_rank(severity)
+        evidence = body.get("evidence")
+        if not isinstance(evidence, dict) or not evidence.get("deterministic"):
+            raise ValueError("signal evidence must be deterministic")
+        impacts = body.get("impacts")
+        if not isinstance(impacts, list) or not impacts:
+            raise ValueError("signal impacts are required")
+        normalized_impacts = []
+        for raw_impact in impacts:
+            impact = _closed_object(
+                raw_impact,
+                {"target_type", "target_uid", "label"},
+                "signal impact",
+            )
+            target_type = _string(
+                impact.get("target_type"), "impact target_type", 40
+            )
+            if target_type not in IMPACT_TYPES:
+                raise ValueError("impact target_type is invalid")
+            normalized_impacts.append(
+                {
+                    "target_type": target_type,
+                    "target_uid": _string(
+                        impact.get("target_uid"), "impact target_uid", 500
+                    ),
+                    "label": _string(impact.get("label"), "impact label", 300),
+                }
+            )
+        observed_at = _timestamp(body.get("observed_at"), "observed_at")
+        return {
+            "source_event_key": _string(
+                body.get("source_event_key"), "source_event_key", 500
+            ),
+            "source_type": _string(
+                body.get("source_type"), "source_type", 80
+            ),
+            "source_uid": _string(body.get("source_uid"), "source_uid", 500),
+            "correlation_key": _string(
+                body.get("correlation_key"), "correlation_key", 500
+            ),
+            "layer": layer,
+            "sli_type": sli_type,
+            "status": status,
+            "severity": severity,
+            "title": _string(body.get("title"), "title", 300),
+            "owner_uid": _uid(body.get("owner_uid"), "owner_uid"),
+            "observed_at": observed_at.isoformat(),
+            "actual": _number(body.get("actual"), "actual"),
+            "target": _number(body.get("target"), "target"),
+            "evidence": dict(evidence),
+            "impacts": normalized_impacts,
+        }
+
+    def _process_signal(
+        self,
+        signal: dict[str, Any],
+        *,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        now = self.now_factory()
+        dedup_key = _canonical_hash(
+            {
+                "correlation_key": signal["correlation_key"],
+                "sli_type": signal["sli_type"],
+            }
+        )
+        alert = self.repository.find_alert(dedup_key)
+        if signal["status"] == "recovered":
+            result = self._recover(
+                signal,
+                alert=alert,
+                dedup_key=dedup_key,
+                actor_uid=actor_uid,
+                now=now,
+            )
+        else:
+            result = self._violate(
+                signal,
+                alert=alert,
+                dedup_key=dedup_key,
+                actor_uid=actor_uid,
+                now=now,
+            )
+        return result
+
+    def _violate(
+        self,
+        signal: dict[str, Any],
+        *,
+        alert: dict[str, Any] | None,
+        dedup_key: str,
+        actor_uid: str,
+        now: datetime,
+    ) -> dict[str, Any]:
+        if alert is None or alert["status"] == "recovered":
+            alert = {
+                "uid": self.uid_factory(),
+                "dedup_key": dedup_key,
+                "incident_uid": None,
+                "layer": signal["layer"],
+                "sli_type": signal["sli_type"],
+                "title": signal["title"],
+                "severity": signal["severity"],
+                "status": "open",
+                "occurrence_count": 1,
+                "escalation_level": 1,
+                "owner_uid": signal["owner_uid"],
+                "first_observed_at": signal["observed_at"],
+                "last_observed_at": signal["observed_at"],
+                "suppressed_until": None,
+                "suppression_reason": None,
+                "delivery_status": "pending",
+                "delivery_receipt": {},
+                "evidence": signal["evidence"],
+                "created_at": now.isoformat(),
+                "updated_at": now.isoformat(),
+            }
+            alert_counter = "created_alerts"
+        else:
+            alert["occurrence_count"] = int(alert["occurrence_count"]) + 1
+            alert["escalation_level"] = min(
+                3, int(alert["escalation_level"]) + 1
+            )
+            if _severity_rank(signal["severity"]) > _severity_rank(
+                alert["severity"]
+            ):
+                alert["severity"] = signal["severity"]
+            alert["last_observed_at"] = signal["observed_at"]
+            suppressed_until = alert.get("suppressed_until")
+            if not suppressed_until or _timestamp(
+                suppressed_until, "suppressed_until"
+            ) <= now:
+                alert["status"] = "open"
+                alert["delivery_status"] = "pending"
+            alert["evidence"] = signal["evidence"]
+            alert["updated_at"] = now.isoformat()
+            alert_counter = "aggregated_alerts"
+
+        incident = self.repository.find_open_incident(dedup_key)
+        if incident is None:
+            incident_uid = self.uid_factory()
+            incident = {
+                "uid": incident_uid,
+                "code": f"INC-{now:%Y%m%d}-{incident_uid[-6:].upper()}",
+                "dedup_key": dedup_key,
+                "title": signal["title"],
+                "severity": signal["severity"],
+                "status": "open",
+                "owner_uid": signal["owner_uid"],
+                "escalation_level": alert["escalation_level"],
+                "first_detected_at": signal["observed_at"],
+                "last_observed_at": signal["observed_at"],
+                "created_by": actor_uid,
+                "created_at": now.isoformat(),
+                "updated_at": now.isoformat(),
+                "closed_by": None,
+                "closed_at": None,
+            }
+            self.repository.create_incident(incident)
+        else:
+            incident["last_observed_at"] = signal["observed_at"]
+            incident["escalation_level"] = max(
+                int(incident["escalation_level"]),
+                int(alert["escalation_level"]),
+            )
+            if _severity_rank(signal["severity"]) > _severity_rank(
+                incident["severity"]
+            ):
+                incident["severity"] = signal["severity"]
+            incident["status"] = "open"
+            incident["updated_at"] = now.isoformat()
+            self.repository.update_incident(incident)
+        alert["incident_uid"] = incident["uid"]
+        if alert_counter == "created_alerts":
+            self.repository.create_alert(alert)
+            action = "alert_created"
+        else:
+            self.repository.update_alert(alert)
+            action = "alert_aggregated"
+        self.repository.save_impacts(incident["uid"], signal["impacts"])
+        self._timeline(
+            incident["uid"],
+            action,
+            actor_uid=actor_uid,
+            evidence={
+                **signal["evidence"],
+                "source_event_key": signal["source_event_key"],
+                "alert_uid": alert["uid"],
+            },
+            now=now,
+        )
+        return {"counter": alert_counter, "incident_uid": incident["uid"]}
+
+    def _recover(
+        self,
+        signal: dict[str, Any],
+        *,
+        alert: dict[str, Any] | None,
+        dedup_key: str,
+        actor_uid: str,
+        now: datetime,
+    ) -> dict[str, Any]:
+        if alert is None or alert.get("incident_uid") is None:
+            return {"counter": "ignored", "incident_uid": None}
+        alert["status"] = "recovered"
+        alert["last_observed_at"] = signal["observed_at"]
+        alert["delivery_status"] = "recovery_pending"
+        alert["evidence"] = signal["evidence"]
+        alert["updated_at"] = now.isoformat()
+        self.repository.update_alert(alert)
+        incident = self.repository.get_incident(alert["incident_uid"])
+        if incident is None:
+            return {"counter": "ignored", "incident_uid": None}
+        incident["status"] = "monitoring"
+        incident["last_observed_at"] = signal["observed_at"]
+        incident["updated_at"] = now.isoformat()
+        self.repository.update_incident(incident)
+        self._timeline(
+            incident["uid"],
+            "recovered",
+            actor_uid=actor_uid,
+            evidence={
+                **signal["evidence"],
+                "source_event_key": signal["source_event_key"],
+                "alert_uid": alert["uid"],
+            },
+            now=now,
+        )
+        return {
+            "counter": "recovered_alerts",
+            "incident_uid": incident["uid"],
+        }
+
+    def suppress_alert(
+        self,
+        alert_uid: str,
+        *,
+        until: datetime | str,
+        reason: str,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        alert = self.repository.get_alert(_uid(alert_uid, "alert_uid"))
+        if alert is None:
+            raise LookupError("alert was not found")
+        if alert["status"] == "recovered":
+            raise RuntimeError("recovered alert cannot be suppressed")
+        deadline = _timestamp(until, "until")
+        now = self.now_factory()
+        if deadline <= now:
+            raise ValueError("suppression deadline must be in the future")
+        alert.update(
+            {
+                "status": "suppressed",
+                "suppressed_until": deadline.isoformat(),
+                "suppression_reason": _string(reason, "reason", 500),
+                "delivery_status": "suppressed",
+                "updated_at": now.isoformat(),
+            }
+        )
+        return self._save_alert_action(
+            alert,
+            action="suppressed",
+            actor_uid=actor_uid,
+            evidence={"reason": alert["suppression_reason"], "deterministic": True},
+        )
+
+    def acknowledge_delivery(
+        self,
+        alert_uid: str,
+        *,
+        channel: str,
+        receipt_id: str,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        alert = self.repository.get_alert(_uid(alert_uid, "alert_uid"))
+        if alert is None:
+            raise LookupError("alert was not found")
+        now = self.now_factory()
+        alert.update(
+            {
+                "delivery_status": "acknowledged",
+                "delivery_receipt": {
+                    "channel": _string(channel, "channel", 80),
+                    "receipt_id": _string(receipt_id, "receipt_id", 200),
+                    "acknowledged_by": _uid(actor_uid, "actor_uid"),
+                    "acknowledged_at": now.isoformat(),
+                },
+                "updated_at": now.isoformat(),
+            }
+        )
+        return self._save_alert_action(
+            alert,
+            action="delivery_acknowledged",
+            actor_uid=actor_uid,
+            evidence={
+                "receipt_id": alert["delivery_receipt"]["receipt_id"],
+                "deterministic": True,
+            },
+        )
+
+    def _save_alert_action(
+        self,
+        alert: dict[str, Any],
+        *,
+        action: str,
+        actor_uid: str,
+        evidence: dict[str, Any],
+    ) -> dict[str, Any]:
+        try:
+            stored = self.repository.update_alert(alert)
+            self._timeline(
+                alert["incident_uid"],
+                action,
+                actor_uid=_uid(actor_uid, "actor_uid"),
+                evidence=evidence,
+                now=self.now_factory(),
+            )
+            self.commit()
+            return stored
+        except Exception:
+            self.rollback()
+            raise
+
+    def escalate_incident(
+        self,
+        incident_uid: str,
+        *,
+        reason: str,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(incident_uid, "incident_uid")
+        incident = self.repository.get_incident(uid)
+        if incident is None:
+            raise LookupError("incident was not found")
+        if incident["status"] == "closed":
+            raise RuntimeError("closed incident cannot be escalated")
+        incident["escalation_level"] = min(
+            3, int(incident["escalation_level"]) + 1
+        )
+        incident["updated_at"] = self.now_factory().isoformat()
+        try:
+            stored = self.repository.update_incident(incident)
+            self._timeline(
+                uid,
+                "escalated",
+                actor_uid=_uid(actor_uid, "actor_uid"),
+                evidence={
+                    "reason": _string(reason, "reason", 500),
+                    "level": incident["escalation_level"],
+                    "deterministic": True,
+                },
+                now=self.now_factory(),
+            )
+            self.commit()
+            return stored
+        except Exception:
+            self.rollback()
+            raise
+
+    def close_incident(
+        self,
+        incident_uid: str,
+        payload: Any,
+        *,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(incident_uid, "incident_uid")
+        body = _closed_object(
+            payload,
+            {
+                "root_cause",
+                "user_impact",
+                "corrective_actions",
+                "closure_evidence",
+            },
+            "incident postmortem",
+        )
+        required = {
+            "root_cause",
+            "user_impact",
+            "corrective_actions",
+            "closure_evidence",
+        }
+        if set(body) != required:
+            raise ValueError("incident postmortem evidence is incomplete")
+        detail = self.repository.incident_detail(uid)
+        if detail is None:
+            raise LookupError("incident was not found")
+        if detail["status"] == "closed":
+            return detail
+        if not detail.get("owner_uid") or not detail.get("impacts"):
+            raise RuntimeError("incident responsibility and impact are required")
+        if not detail.get("alerts") or any(
+            item["status"] != "recovered" for item in detail["alerts"]
+        ):
+            raise RuntimeError("all incident alerts must recover before closure")
+        actions = body["corrective_actions"]
+        evidence = body["closure_evidence"]
+        if (
+            not isinstance(actions, list)
+            or not actions
+            or not isinstance(evidence, list)
+            or not evidence
+        ):
+            raise ValueError("incident postmortem evidence is incomplete")
+        normalized_actions = [
+            _string(item, "corrective action", 500) for item in actions
+        ]
+        normalized_evidence = []
+        for item in evidence:
+            value = _closed_object(
+                item, {"kind", "ref"}, "closure evidence"
+            )
+            normalized_evidence.append(
+                {
+                    "kind": _string(value.get("kind"), "evidence kind", 80),
+                    "ref": _string(value.get("ref"), "evidence ref", 500),
+                }
+            )
+        actor = _uid(actor_uid, "actor_uid")
+        now = self.now_factory()
+        postmortem = {
+            "uid": self.uid_factory(),
+            "incident_uid": uid,
+            "root_cause": _string(
+                body.get("root_cause"), "root_cause", 2_000
+            ),
+            "user_impact": _string(
+                body.get("user_impact"), "user_impact", 2_000
+            ),
+            "corrective_actions": normalized_actions,
+            "closure_evidence": normalized_evidence,
+            "created_by": actor,
+            "created_at": now.isoformat(),
+        }
+        incident = self.repository.get_incident(uid)
+        incident.update(
+            {
+                "status": "closed",
+                "closed_by": actor,
+                "closed_at": now.isoformat(),
+                "updated_at": now.isoformat(),
+            }
+        )
+        try:
+            self.repository.save_postmortem(postmortem)
+            self.repository.update_incident(incident)
+            self._timeline(
+                uid,
+                "closed",
+                actor_uid=actor,
+                evidence={
+                    "postmortem_uid": postmortem["uid"],
+                    "closure_evidence_count": len(normalized_evidence),
+                    "deterministic": True,
+                },
+                now=now,
+            )
+            self.commit()
+            return self.repository.incident_detail(uid)
+        except Exception:
+            self.rollback()
+            raise
+
+    def _timeline(
+        self,
+        incident_uid: str,
+        action: str,
+        *,
+        actor_uid: str,
+        evidence: dict[str, Any],
+        now: datetime,
+    ) -> None:
+        _reject_secret_material(evidence)
+        self.repository.append_timeline(
+            {
+                "uid": self.uid_factory(),
+                "incident_uid": incident_uid,
+                "action": action,
+                "actor_uid": actor_uid,
+                "evidence": evidence,
+                "created_at": now.isoformat(),
+            }
+        )
+
+    def list_alerts(self) -> list[dict[str, Any]]:
+        return self.repository.list_alerts()
+
+    def list_incidents(self) -> list[dict[str, Any]]:
+        return self.repository.list_incidents()
+
+    def incident_detail(self, uid: str) -> dict[str, Any]:
+        value = self.repository.incident_detail(_uid(uid, "incident_uid"))
+        if value is None:
+            raise LookupError("incident was not found")
+        return value
+
+    def overview(self) -> dict[str, Any]:
+        counts = self.repository.overview_counts()
+        return {
+            "layers": {layer: dict(counts.get(layer) or {}) for layer in LAYERS},
+            "sli_catalog": self.sli_catalog(),
+            "slos": self.list_slos(),
+            "open_incident_count": sum(
+                item["status"] != "closed" for item in self.list_incidents()
+            ),
+        }

+ 759 - 0
app/core/events/data_observability_repository.py

@@ -0,0 +1,759 @@
+"""PostgreSQL persistence and canonical signal collection for data incidents."""
+
+from __future__ import annotations
+
+import json
+import uuid
+from typing import Any
+
+from sqlalchemy import text
+
+
+def _json(value: Any) -> str:
+    return json.dumps(value, ensure_ascii=False, sort_keys=True)
+
+
+def _row(value):
+    if value is None:
+        return None
+    result = dict(value)
+    for key, item in tuple(result.items()):
+        if isinstance(item, uuid.UUID):
+            result[key] = str(item)
+        elif hasattr(item, "isoformat"):
+            result[key] = item.isoformat()
+        elif isinstance(item, dict):
+            result[key] = dict(item)
+    return result
+
+
+class SqlAlchemyDataObservabilityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def create_slo(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_slo_policies (
+                    uid, code, name, sli_type, scope_type, scope_uid,
+                    operator, target, window_seconds, owner_uid, status,
+                    created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :name, :sli_type,
+                    :scope_type, :scope_uid, :operator, :target,
+                    :window_seconds, CAST(:owner_uid AS uuid), :status,
+                    CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                """
+            ),
+            record,
+        )
+        return self.get_slo(record["uid"])
+
+    def get_slo(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, code, name, sli_type, scope_type, scope_uid,
+                           operator, target, window_seconds, owner_uid, status,
+                           created_by, created_at, updated_at
+                    FROM public.data_slo_policies
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def list_slos(self):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, code, name, sli_type, scope_type, scope_uid,
+                           operator, target, window_seconds, owner_uid, status,
+                           created_by, created_at, updated_at
+                    FROM public.data_slo_policies
+                    ORDER BY status, sli_type, code
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        ]
+
+    def pending_signals(self):
+        signals = self._pending_quality_signals()
+        signals.extend(self._pending_metadata_signals())
+        return sorted(
+            signals,
+            key=lambda item: (item["observed_at"], item["source_event_key"]),
+        )
+
+    def _pending_quality_signals(self):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT event.uid, event.run_uid, event.asset_uid,
+                       event.sla_type, event.status, event.severity,
+                       event.actual, event.threshold, event.owner_uid,
+                       event.escalation_level, event.evidence,
+                       event.created_at, run.business_domain_uid,
+                       asset.namespace, asset.name, asset.snapshot,
+                       product.id AS product_id,
+                       product.product_name
+                FROM public.quality_sla_events event
+                JOIN public.quality_profile_runs run ON run.uid = event.run_uid
+                JOIN public.active_metadata_assets asset
+                  ON asset.uid = event.asset_uid
+                LEFT JOIN public.data_products product
+                  ON product.target_schema = asset.namespace
+                 AND product.target_table = asset.name
+                WHERE event.status IN ('violated','recovered')
+                  AND NOT EXISTS (
+                    SELECT 1
+                    FROM public.data_observability_source_events consumed
+                    WHERE consumed.source_event_key =
+                        'quality-sla:' || event.uid::text || ':' || event.status
+                  )
+                ORDER BY event.created_at, event.uid
+                """
+            )
+        ).mappings()
+        results = []
+        for raw in rows:
+            row = _row(raw)
+            status = (
+                "violated"
+                if row["status"] == "violated"
+                else "recovered"
+            )
+            snapshot = row.get("snapshot") or {}
+            impacts = [
+                {
+                    "target_type": "asset",
+                    "target_uid": row["asset_uid"],
+                    "label": f"{row['namespace']}.{row['name']}",
+                }
+            ]
+            domain_uid = (
+                row.get("business_domain_uid")
+                or snapshot.get("business_domain_uid")
+            )
+            if domain_uid:
+                impacts.append(
+                    {
+                        "target_type": "business_domain",
+                        "target_uid": str(domain_uid),
+                        "label": str(
+                            snapshot.get("business_domain_name") or domain_uid
+                        ),
+                    }
+                )
+            if row.get("product_id") is not None:
+                impacts.append(
+                    {
+                        "target_type": "data_product",
+                        "target_uid": str(row["product_id"]),
+                        "label": str(row["product_name"]),
+                    }
+                )
+            if snapshot.get("user_impact_group_uid"):
+                impacts.append(
+                    {
+                        "target_type": "user_group",
+                        "target_uid": str(
+                            snapshot["user_impact_group_uid"]
+                        ),
+                        "label": str(
+                            snapshot.get("user_impact_group_name")
+                            or snapshot["user_impact_group_uid"]
+                        ),
+                    }
+                )
+            sli_type = (
+                "freshness"
+                if row["sla_type"] == "freshness"
+                else "quality"
+            )
+            results.append(
+                {
+                    "source_event_key": (
+                        f"quality-sla:{row['uid']}:{status}"
+                    ),
+                    "source_type": "quality_sla_event",
+                    "source_uid": row["uid"],
+                    "correlation_key": (
+                        f"quality-sla:{row['asset_uid']}:{row['sla_type']}"
+                    ),
+                    "layer": "data",
+                    "sli_type": sli_type,
+                    "status": status,
+                    "severity": (
+                        row["severity"] if status == "violated" else "info"
+                    ),
+                    "title": (
+                        f"{row['namespace']}.{row['name']} "
+                        f"{row['sla_type']} SLA "
+                        f"{'违约' if status == 'violated' else '恢复'}"
+                    ),
+                    "owner_uid": row["owner_uid"],
+                    "observed_at": row["created_at"],
+                    "actual": float(row["actual"]),
+                    "target": float(row["threshold"]),
+                    "evidence": {
+                        **(row.get("evidence") or {}),
+                        "quality_sla_event_uid": row["uid"],
+                        "run_uid": row["run_uid"],
+                        "deterministic": True,
+                    },
+                    "impacts": impacts,
+                }
+            )
+        return results
+
+    def _pending_metadata_signals(self):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT run.uid, run.plan_uid, run.batch_key, run.status,
+                       run.attempt_count, run.failure_code, run.failure_reason,
+                       run.statistics, run.finished_at, plan.name,
+                       plan.owner_uid, plan.scope, plan.source_uid
+                FROM public.active_metadata_runs run
+                JOIN public.active_metadata_plans plan
+                  ON plan.uid = run.plan_uid
+                WHERE (
+                    run.status = 'failed'
+                    OR (
+                        run.status = 'completed'
+                        AND (
+                            SELECT previous_run.status
+                            FROM public.active_metadata_runs previous_run
+                            WHERE previous_run.plan_uid = run.plan_uid
+                              AND (
+                                previous_run.finished_at,
+                                previous_run.uid
+                              ) < (run.finished_at, run.uid)
+                            ORDER BY previous_run.finished_at DESC,
+                                     previous_run.uid DESC
+                            LIMIT 1
+                        ) = 'failed'
+                    )
+                )
+                  AND NOT EXISTS (
+                    SELECT 1
+                    FROM public.data_observability_source_events consumed
+                    WHERE consumed.source_event_key =
+                        'active-metadata:' || run.plan_uid::text || ':' ||
+                        run.uid::text || ':' ||
+                        CASE WHEN run.status = 'failed'
+                             THEN 'violated' ELSE 'recovered' END
+                  )
+                ORDER BY run.finished_at, run.uid
+                """
+            )
+        ).mappings()
+        results = []
+        for raw in rows:
+            row = _row(raw)
+            scope = row.get("scope") or {}
+            status = (
+                "violated"
+                if row["status"] == "failed"
+                else "recovered"
+            )
+            impacts = [
+                {
+                    "target_type": "service",
+                    "target_uid": f"active-metadata-plan:{row['plan_uid']}",
+                    "label": str(row["name"]),
+                }
+            ]
+            bindings = (
+                ("business_domain", "business_domain_uid", "business_domain_name"),
+                ("data_product", "data_product_uid", "data_product_name"),
+                ("user_group", "user_impact_group_uid", "user_impact_group_name"),
+            )
+            for target_type, uid_key, label_key in bindings:
+                if scope.get(uid_key):
+                    impacts.append(
+                        {
+                            "target_type": target_type,
+                            "target_uid": str(scope[uid_key]),
+                            "label": str(
+                                scope.get(label_key) or scope[uid_key]
+                            ),
+                        }
+                    )
+            results.append(
+                {
+                    "source_event_key": (
+                        f"active-metadata:{row['plan_uid']}:"
+                        f"{row['uid']}:{status}"
+                    ),
+                    "source_type": "active_metadata_run",
+                    "source_uid": row["uid"],
+                    "correlation_key": (
+                        f"active-metadata-plan:{row['plan_uid']}"
+                    ),
+                    "layer": "task",
+                    "sli_type": "delivery",
+                    "status": status,
+                    "severity": "critical" if status == "violated" else "info",
+                    "title": (
+                        f"{row['name']}采集"
+                        f"{'失败' if status == 'violated' else '恢复'}"
+                    ),
+                    "owner_uid": row["owner_uid"],
+                    "observed_at": row["finished_at"],
+                    "actual": 0 if status == "violated" else 1,
+                    "target": 1,
+                    "evidence": {
+                        "run_uid": row["uid"],
+                        "plan_uid": row["plan_uid"],
+                        "source_uid": row["source_uid"],
+                        "batch_key": row["batch_key"],
+                        "attempt_count": row["attempt_count"],
+                        "failure_code": row.get("failure_code"),
+                        "failure_reason": row.get("failure_reason"),
+                        "statistics": row.get("statistics") or {},
+                        "deterministic": True,
+                    },
+                    "impacts": impacts,
+                }
+            )
+        return results
+
+    def source_event_exists(self, source_event_key):
+        return bool(
+            self.session.execute(
+                text(
+                    """
+                    SELECT 1
+                    FROM public.data_observability_source_events
+                    WHERE source_event_key = :source_event_key
+                    """
+                ),
+                {"source_event_key": source_event_key},
+            ).scalar()
+        )
+
+    def record_source_event(self, record):
+        result = self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_observability_source_events (
+                    uid, source_event_key, source_type, source_uid, status,
+                    signal, processed_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :source_event_key, :source_type,
+                    :source_uid, :status, CAST(:signal AS jsonb),
+                    CAST(:processed_at AS timestamptz)
+                ) ON CONFLICT (source_event_key) DO NOTHING
+                """
+            ),
+            {**record, "signal": _json(record["signal"])},
+        )
+        return result.rowcount == 1
+
+    def find_alert(self, dedup_key):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT *
+                    FROM public.data_observability_alerts
+                    WHERE dedup_key = :dedup_key
+                    ORDER BY last_observed_at DESC, uid DESC
+                    LIMIT 1
+                    """
+                ),
+                {"dedup_key": dedup_key},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def get_alert(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT *
+                    FROM public.data_observability_alerts
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def create_alert(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_observability_alerts (
+                    uid, dedup_key, incident_uid, layer, sli_type, title,
+                    severity, status, occurrence_count, escalation_level,
+                    owner_uid, first_observed_at, last_observed_at,
+                    suppressed_until, suppression_reason, delivery_status,
+                    delivery_receipt, evidence, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :dedup_key,
+                    CAST(:incident_uid AS uuid), :layer, :sli_type, :title,
+                    :severity, :status, :occurrence_count, :escalation_level,
+                    CAST(:owner_uid AS uuid),
+                    CAST(:first_observed_at AS timestamptz),
+                    CAST(:last_observed_at AS timestamptz),
+                    CAST(:suppressed_until AS timestamptz),
+                    :suppression_reason, :delivery_status,
+                    CAST(:delivery_receipt AS jsonb), CAST(:evidence AS jsonb),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **record,
+                "delivery_receipt": _json(record["delivery_receipt"]),
+                "evidence": _json(record["evidence"]),
+            },
+        )
+        return self.get_alert(record["uid"])
+
+    def update_alert(self, record):
+        self.session.execute(
+            text(
+                """
+                UPDATE public.data_observability_alerts
+                SET severity = :severity, status = :status,
+                    occurrence_count = :occurrence_count,
+                    escalation_level = :escalation_level,
+                    last_observed_at = CAST(:last_observed_at AS timestamptz),
+                    suppressed_until = CAST(:suppressed_until AS timestamptz),
+                    suppression_reason = :suppression_reason,
+                    delivery_status = :delivery_status,
+                    delivery_receipt = CAST(:delivery_receipt AS jsonb),
+                    evidence = CAST(:evidence AS jsonb),
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                """
+            ),
+            {
+                **record,
+                "delivery_receipt": _json(record["delivery_receipt"]),
+                "evidence": _json(record["evidence"]),
+            },
+        )
+        return self.get_alert(record["uid"])
+
+    def find_open_incident(self, dedup_key):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT *
+                    FROM public.data_incidents
+                    WHERE dedup_key = :dedup_key AND status <> 'closed'
+                    ORDER BY created_at DESC LIMIT 1
+                    """
+                ),
+                {"dedup_key": dedup_key},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def get_incident(self, uid):
+        return _row(
+            self.session.execute(
+                text("SELECT * FROM public.data_incidents WHERE uid = CAST(:uid AS uuid)"),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def create_incident(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_incidents (
+                    uid, code, dedup_key, title, severity, status, owner_uid,
+                    escalation_level, first_detected_at, last_observed_at,
+                    created_by, created_at, updated_at, closed_by, closed_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :dedup_key, :title, :severity,
+                    :status, CAST(:owner_uid AS uuid), :escalation_level,
+                    CAST(:first_detected_at AS timestamptz),
+                    CAST(:last_observed_at AS timestamptz),
+                    CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz),
+                    CAST(:closed_by AS uuid), CAST(:closed_at AS timestamptz)
+                )
+                """
+            ),
+            record,
+        )
+        return self.get_incident(record["uid"])
+
+    def update_incident(self, record):
+        self.session.execute(
+            text(
+                """
+                UPDATE public.data_incidents
+                SET severity = :severity, status = :status,
+                    owner_uid = CAST(:owner_uid AS uuid),
+                    escalation_level = :escalation_level,
+                    last_observed_at = CAST(:last_observed_at AS timestamptz),
+                    updated_at = CAST(:updated_at AS timestamptz),
+                    closed_by = CAST(:closed_by AS uuid),
+                    closed_at = CAST(:closed_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                """
+            ),
+            record,
+        )
+        return self.get_incident(record["uid"])
+
+    def save_impacts(self, incident_uid, impacts):
+        for impact in impacts:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.data_incident_impacts (
+                        incident_uid, target_type, target_uid, label
+                    ) VALUES (
+                        CAST(:incident_uid AS uuid), :target_type,
+                        :target_uid, :label
+                    ) ON CONFLICT (
+                        incident_uid, target_type, target_uid
+                    ) DO UPDATE SET label = EXCLUDED.label
+                    """
+                ),
+                {"incident_uid": incident_uid, **impact},
+            )
+
+    def append_timeline(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_incident_timeline (
+                    uid, incident_uid, action, actor_uid, evidence, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:incident_uid AS uuid), :action,
+                    CAST(:actor_uid AS uuid), CAST(:evidence AS jsonb),
+                    CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {**record, "evidence": _json(record["evidence"])},
+        )
+
+    def save_postmortem(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_incident_postmortems (
+                    uid, incident_uid, root_cause, user_impact,
+                    corrective_actions, closure_evidence, created_by,
+                    created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:incident_uid AS uuid),
+                    :root_cause, :user_impact,
+                    CAST(:corrective_actions AS jsonb),
+                    CAST(:closure_evidence AS jsonb),
+                    CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **record,
+                "corrective_actions": _json(record["corrective_actions"]),
+                "closure_evidence": _json(record["closure_evidence"]),
+            },
+        )
+
+    def list_alerts(self):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT * FROM public.data_observability_alerts
+                    ORDER BY status, severity DESC, last_observed_at DESC
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_incidents(self):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT * FROM public.data_incidents
+                    ORDER BY status, severity DESC, updated_at DESC
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        ]
+
+    def incident_detail(self, uid):
+        incident = self.get_incident(uid)
+        if incident is None:
+            return None
+        alerts = [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT * FROM public.data_observability_alerts
+                    WHERE incident_uid = CAST(:uid AS uuid)
+                    ORDER BY first_observed_at, uid
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+        impacts = [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT target_type, target_uid, label, created_at
+                    FROM public.data_incident_impacts
+                    WHERE incident_uid = CAST(:uid AS uuid)
+                    ORDER BY target_type, target_uid
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+        timeline = [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, incident_uid, action, actor_uid, evidence,
+                           created_at
+                    FROM public.data_incident_timeline
+                    WHERE incident_uid = CAST(:uid AS uuid)
+                    ORDER BY created_at, uid
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+        postmortem = _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, incident_uid, root_cause, user_impact,
+                           corrective_actions, closure_evidence, created_by,
+                           created_at
+                    FROM public.data_incident_postmortems
+                    WHERE incident_uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return {
+            **incident,
+            "alerts": alerts,
+            "impacts": impacts,
+            "timeline": timeline,
+            "postmortem": postmortem,
+        }
+
+    def overview_counts(self):
+        result = {
+            layer: {"healthy": 0, "warning": 0, "critical": 0}
+            for layer in ("service", "task", "data", "capacity")
+        }
+        rows = self.session.execute(
+            text(
+                """
+                SELECT layer,
+                       COUNT(*) FILTER (
+                           WHERE status = 'recovered'
+                       ) AS healthy,
+                       COUNT(*) FILTER (
+                           WHERE status <> 'recovered'
+                             AND severity IN ('info','warning')
+                       ) AS warning,
+                       COUNT(*) FILTER (
+                           WHERE status <> 'recovered'
+                             AND severity IN ('error','critical')
+                       ) AS critical
+                FROM public.data_observability_alerts
+                GROUP BY layer
+                """
+            )
+        ).mappings()
+        for row in rows:
+            result[row["layer"]] = {
+                "healthy": int(row["healthy"] or 0),
+                "warning": int(row["warning"] or 0),
+                "critical": int(row["critical"] or 0),
+            }
+        service = self.session.execute(
+            text(
+                """
+                SELECT
+                    COUNT(*) FILTER (
+                        WHERE status IN ('pending','processing')
+                    ) AS backlog,
+                    COUNT(*) FILTER (
+                        WHERE status IN ('failed','dead_letter')
+                    ) AS failed
+                FROM public.outbox_events
+                """
+            )
+        ).mappings().one()
+        backlog = int(service["backlog"] or 0)
+        failed = int(service["failed"] or 0)
+        result["service"] = {
+            "healthy": int(backlog == 0 and failed == 0),
+            "warning": int(backlog > 0 and failed == 0),
+            "critical": int(failed > 0),
+            "evidence": {"outbox_backlog": backlog, "outbox_failed": failed},
+        }
+        database_size = int(
+            self.session.execute(
+                text("SELECT pg_database_size(current_database())")
+            ).scalar()
+            or 0
+        )
+        result["capacity"]["unknown"] = 1
+        result["capacity"]["evidence"] = {
+            "database_size_bytes": database_size,
+            "capacity_limit": "not_configured",
+        }
+        return result

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

@@ -55,6 +55,9 @@ ACTIVE_METADATA_MANAGE = "active-metadata:manage"
 SEMANTIC_GOVERNANCE_EDIT = "semantic-governance:edit"
 SEMANTIC_GOVERNANCE_REVIEW = "semantic-governance:review"
 SEMANTIC_GOVERNANCE_PUBLISH = "semantic-governance:publish"
+DATA_OBSERVABILITY_READ = "data-observability:read"
+DATA_OBSERVABILITY_OPERATE = "data-observability:operate"
+DATA_OBSERVABILITY_MANAGE = "data-observability:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -64,6 +67,7 @@ ROLE_PERMISSIONS = {
             RESPONSIBILITIES_READ,
             DOMAIN_TEMPLATES_READ,
             ACTIVE_METADATA_READ,
+            DATA_OBSERVABILITY_READ,
         }
     ),
     "editor": frozenset(
@@ -91,6 +95,8 @@ ROLE_PERMISSIONS = {
             ACTIVE_METADATA_READ,
             ACTIVE_METADATA_OPERATE,
             SEMANTIC_GOVERNANCE_EDIT,
+            DATA_OBSERVABILITY_READ,
+            DATA_OBSERVABILITY_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -143,6 +149,9 @@ ROLE_PERMISSIONS = {
             SEMANTIC_GOVERNANCE_EDIT,
             SEMANTIC_GOVERNANCE_REVIEW,
             SEMANTIC_GOVERNANCE_PUBLISH,
+            DATA_OBSERVABILITY_READ,
+            DATA_OBSERVABILITY_OPERATE,
+            DATA_OBSERVABILITY_MANAGE,
         }
     ),
 }
@@ -175,6 +184,12 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path == "/api/meta/active-metadata/plans":
             return (ACTIVE_METADATA_MANAGE,)
         return (ACTIVE_METADATA_OPERATE,)
+    if path.startswith("/api/datafactory/observability"):
+        if method == "GET":
+            return (DATA_OBSERVABILITY_READ,)
+        if path == "/api/datafactory/observability/slos":
+            return (DATA_OBSERVABILITY_MANAGE,)
+        return (DATA_OBSERVABILITY_OPERATE,)
     if path in {"/api/knowledge/search", "/api/knowledge/ask"}:
         return (READ_GOVERNANCE,)
     if path.startswith("/api/rules"):

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

@@ -3,3 +3,4 @@ from flask import Blueprint
 bp = Blueprint("data_factory", __name__)
 
 from app.api.data_factory import routes  # noqa: E402, F401
+from app.api.data_factory import observability_routes  # noqa: E402, F401

+ 173 - 0
deployment/app/api/data_factory/observability_routes.py

@@ -0,0 +1,173 @@
+"""HTTP boundary for SLO monitoring and evidence-bound data incidents."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.data_factory import bp
+from app.models.result import failed, success
+
+
+def get_data_observability_service():
+    from app.core.events.data_observability import DataObservabilityService
+    from app.core.events.data_observability_repository import (
+        SqlAlchemyDataObservabilityRepository,
+    )
+
+    return DataObservabilityService(
+        SqlAlchemyDataObservabilityRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _actor_uid():
+    identity = getattr(g, "current_user", {}) or {}
+    return identity.get("id") or identity.get("sub")
+
+
+def _payload():
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict):
+        raise ValueError("request body must be an object")
+    return value
+
+
+def _error(error):
+    db.session.rollback()
+    if isinstance(error, LookupError):
+        return jsonify(failed(str(error), code=404)), 404
+    if isinstance(error, ValueError):
+        return jsonify(failed(str(error), code=400)), 400
+    if isinstance(error, RuntimeError):
+        return jsonify(failed(str(error), code=409)), 409
+    return jsonify(failed("数据可观测与事故请求处理失败", code=500)), 500
+
+
+@bp.get("/observability/overview")
+def get_observability_overview():
+    try:
+        return jsonify(success(get_data_observability_service().overview())), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/observability/slos")
+def list_slo_policies():
+    try:
+        return jsonify(success(get_data_observability_service().list_slos())), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/slos")
+def create_slo_policy():
+    try:
+        record = get_data_observability_service().create_slo(
+            _payload(), actor_uid=_actor_uid()
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/collect")
+def collect_observability_signals():
+    try:
+        _payload()
+        record = get_data_observability_service().collect(
+            actor_uid=_actor_uid()
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/observability/alerts")
+def list_observability_alerts():
+    try:
+        return jsonify(
+            success(get_data_observability_service().list_alerts())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/alerts/<alert_uid>/suppress")
+def suppress_observability_alert(alert_uid):
+    try:
+        payload = _payload()
+        record = get_data_observability_service().suppress_alert(
+            alert_uid,
+            until=payload.get("until"),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/alerts/<alert_uid>/acknowledge-delivery")
+def acknowledge_observability_alert_delivery(alert_uid):
+    try:
+        payload = _payload()
+        record = get_data_observability_service().acknowledge_delivery(
+            alert_uid,
+            channel=payload.get("channel"),
+            receipt_id=payload.get("receipt_id"),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/observability/incidents")
+def list_data_incidents():
+    try:
+        return jsonify(
+            success(get_data_observability_service().list_incidents())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/observability/incidents/<incident_uid>")
+def get_data_incident(incident_uid):
+    try:
+        return jsonify(
+            success(
+                get_data_observability_service().incident_detail(incident_uid)
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/incidents/<incident_uid>/escalate")
+def escalate_data_incident(incident_uid):
+    try:
+        payload = _payload()
+        record = get_data_observability_service().escalate_incident(
+            incident_uid,
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/incidents/<incident_uid>/close")
+def close_data_incident(incident_uid):
+    try:
+        record = get_data_observability_service().close_incident(
+            incident_uid,
+            _payload(),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)

+ 818 - 0
deployment/app/core/events/data_observability.py

@@ -0,0 +1,818 @@
+"""Cross-domain data observability, alert aggregation and incident operations."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+import re
+import uuid
+from collections.abc import Callable
+from datetime import datetime
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+
+SLI_CATALOG = (
+    {
+        "type": "freshness",
+        "name": "新鲜度",
+        "unit": "seconds",
+        "description": "源数据观测时间到当前时间的延迟",
+    },
+    {
+        "type": "completeness",
+        "name": "完整性",
+        "unit": "ratio",
+        "description": "约定字段和记录满足完整性要求的比例",
+    },
+    {
+        "type": "quality",
+        "name": "质量",
+        "unit": "score",
+        "description": "确定性质量规则计算得到的质量分数",
+    },
+    {
+        "type": "delivery",
+        "name": "交付",
+        "unit": "ratio",
+        "description": "约定窗口内成功完成采集或交付的比例",
+    },
+)
+SLI_TYPES = frozenset(item["type"] for item in SLI_CATALOG)
+LAYERS = ("service", "task", "data", "capacity")
+SEVERITIES = ("info", "warning", "error", "critical")
+IMPACT_TYPES = frozenset(
+    {"asset", "data_product", "business_domain", "user_group", "service"}
+)
+OPERATORS = frozenset({">=", "<="})
+SECRET_TOKENS = frozenset(
+    {
+        "apikey",
+        "authorization",
+        "connectionstring",
+        "credential",
+        "dsn",
+        "password",
+        "secret",
+        "token",
+    }
+)
+CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,119}$")
+
+
+def _normalized_key(value: Any) -> str:
+    return re.sub(r"[^a-z0-9]", "", str(value).casefold())
+
+
+def _reject_secret_material(value: Any, path: str = "$") -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            normalized = _normalized_key(key)
+            if any(token in normalized for token in SECRET_TOKENS):
+                raise ValueError(f"secret material is not allowed at {path}.{key}")
+            _reject_secret_material(item, f"{path}.{key}")
+    elif isinstance(value, list):
+        for index, item in enumerate(value):
+            _reject_secret_material(item, f"{path}[{index}]")
+
+
+def _closed_object(
+    value: Any,
+    allowed: set[str] | frozenset[str],
+    label: str,
+) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise ValueError(f"{label} must be an object")
+    _reject_secret_material(value)
+    unknown = sorted(set(value) - set(allowed))
+    if unknown:
+        raise ValueError(
+            f"{label} contains unsupported fields: {', '.join(unknown)}"
+        )
+    return dict(value)
+
+
+def _string(value: Any, label: str, maximum: int = 500) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    result = value.strip()
+    if len(result) > maximum:
+        raise ValueError(f"{label} exceeds {maximum} characters")
+    return result
+
+
+def _uid(value: Any, label: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (TypeError, ValueError, AttributeError) as error:
+        raise ValueError(f"{label} must be a UUID") from error
+
+
+def _number(value: Any, label: str) -> float:
+    if isinstance(value, bool):
+        raise ValueError(f"{label} must be numeric")
+    try:
+        result = float(value)
+    except (TypeError, ValueError) as error:
+        raise ValueError(f"{label} must be numeric") from error
+    if not math.isfinite(result):
+        raise ValueError(f"{label} must be finite")
+    return result
+
+
+def _timestamp(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 _canonical_hash(value: Any) -> str:
+    serialized = json.dumps(
+        value,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    )
+    return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+
+
+def _severity_rank(value: str) -> int:
+    try:
+        return SEVERITIES.index(value)
+    except ValueError as error:
+        raise ValueError("severity is invalid") from error
+
+
+class DataObservabilityService:
+    """Operate deterministic SLO evidence without inventing root causes."""
+
+    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
+
+    @staticmethod
+    def sli_catalog() -> list[dict[str, str]]:
+        return [dict(item) for item in SLI_CATALOG]
+
+    def create_slo(self, payload: Any, *, actor_uid: str) -> dict[str, Any]:
+        body = _closed_object(
+            payload,
+            {
+                "code",
+                "name",
+                "sli_type",
+                "scope_type",
+                "scope_uid",
+                "operator",
+                "target",
+                "window_seconds",
+                "owner_uid",
+            },
+            "SLO policy",
+        )
+        code = _string(body.get("code"), "code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("SLO policy code is invalid")
+        sli_type = _string(body.get("sli_type"), "sli_type", 30)
+        if sli_type not in SLI_TYPES:
+            raise ValueError("SLO policy sli_type is invalid")
+        operator = _string(body.get("operator"), "operator", 2)
+        if operator not in OPERATORS:
+            raise ValueError("SLO policy operator is invalid")
+        target = _number(body.get("target"), "target")
+        window_seconds = int(_number(body.get("window_seconds"), "window_seconds"))
+        if window_seconds < 60 or window_seconds > 31_536_000:
+            raise ValueError("window_seconds must be between 60 and 31536000")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "code": code,
+            "name": _string(body.get("name"), "name", 300),
+            "sli_type": sli_type,
+            "scope_type": _string(body.get("scope_type"), "scope_type", 40),
+            "scope_uid": _string(body.get("scope_uid"), "scope_uid", 500),
+            "operator": operator,
+            "target": target,
+            "window_seconds": window_seconds,
+            "owner_uid": _uid(body.get("owner_uid"), "owner_uid"),
+            "status": "active",
+            "created_by": _uid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "updated_at": now,
+        }
+        try:
+            stored = self.repository.create_slo(record)
+            self.commit()
+            return stored
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_slos(self) -> list[dict[str, Any]]:
+        return self.repository.list_slos()
+
+    def collect(self, *, actor_uid: str) -> dict[str, Any]:
+        actor = _uid(actor_uid, "actor_uid")
+        counters = {
+            "processed": 0,
+            "ignored": 0,
+            "created_alerts": 0,
+            "aggregated_alerts": 0,
+            "recovered_alerts": 0,
+        }
+        incident_uids: list[str] = []
+        try:
+            for raw_signal in self.repository.pending_signals():
+                signal = self._signal(raw_signal)
+                if self.repository.source_event_exists(
+                    signal["source_event_key"]
+                ):
+                    counters["ignored"] += 1
+                    continue
+                claimed = self.repository.record_source_event(
+                    {
+                        "uid": self.uid_factory(),
+                        "source_event_key": signal["source_event_key"],
+                        "source_type": signal["source_type"],
+                        "source_uid": signal["source_uid"],
+                        "status": signal["status"],
+                        "signal": signal,
+                        "processed_at": self.now_factory().isoformat(),
+                    }
+                )
+                if claimed is False:
+                    counters["ignored"] += 1
+                    continue
+                result = self._process_signal(signal, actor_uid=actor)
+                counters["processed"] += 1
+                counters[result["counter"]] += 1
+                if (
+                    result.get("incident_uid")
+                    and result["incident_uid"] not in incident_uids
+                ):
+                    incident_uids.append(result["incident_uid"])
+            self.commit()
+        except Exception:
+            self.rollback()
+            raise
+        return {**counters, "incident_uids": incident_uids}
+
+    def _signal(self, value: Any) -> dict[str, Any]:
+        body = _closed_object(
+            value,
+            {
+                "source_event_key",
+                "source_type",
+                "source_uid",
+                "correlation_key",
+                "layer",
+                "sli_type",
+                "status",
+                "severity",
+                "title",
+                "owner_uid",
+                "observed_at",
+                "actual",
+                "target",
+                "evidence",
+                "impacts",
+            },
+            "observability signal",
+        )
+        layer = _string(body.get("layer"), "layer", 20)
+        if layer not in LAYERS:
+            raise ValueError("observability signal layer is invalid")
+        sli_type = _string(body.get("sli_type"), "sli_type", 30)
+        if sli_type not in SLI_TYPES:
+            raise ValueError("observability signal sli_type is invalid")
+        status = _string(body.get("status"), "status", 20)
+        if status not in {"violated", "recovered"}:
+            raise ValueError("observability signal status is invalid")
+        severity = _string(body.get("severity"), "severity", 20)
+        _severity_rank(severity)
+        evidence = body.get("evidence")
+        if not isinstance(evidence, dict) or not evidence.get("deterministic"):
+            raise ValueError("signal evidence must be deterministic")
+        impacts = body.get("impacts")
+        if not isinstance(impacts, list) or not impacts:
+            raise ValueError("signal impacts are required")
+        normalized_impacts = []
+        for raw_impact in impacts:
+            impact = _closed_object(
+                raw_impact,
+                {"target_type", "target_uid", "label"},
+                "signal impact",
+            )
+            target_type = _string(
+                impact.get("target_type"), "impact target_type", 40
+            )
+            if target_type not in IMPACT_TYPES:
+                raise ValueError("impact target_type is invalid")
+            normalized_impacts.append(
+                {
+                    "target_type": target_type,
+                    "target_uid": _string(
+                        impact.get("target_uid"), "impact target_uid", 500
+                    ),
+                    "label": _string(impact.get("label"), "impact label", 300),
+                }
+            )
+        observed_at = _timestamp(body.get("observed_at"), "observed_at")
+        return {
+            "source_event_key": _string(
+                body.get("source_event_key"), "source_event_key", 500
+            ),
+            "source_type": _string(
+                body.get("source_type"), "source_type", 80
+            ),
+            "source_uid": _string(body.get("source_uid"), "source_uid", 500),
+            "correlation_key": _string(
+                body.get("correlation_key"), "correlation_key", 500
+            ),
+            "layer": layer,
+            "sli_type": sli_type,
+            "status": status,
+            "severity": severity,
+            "title": _string(body.get("title"), "title", 300),
+            "owner_uid": _uid(body.get("owner_uid"), "owner_uid"),
+            "observed_at": observed_at.isoformat(),
+            "actual": _number(body.get("actual"), "actual"),
+            "target": _number(body.get("target"), "target"),
+            "evidence": dict(evidence),
+            "impacts": normalized_impacts,
+        }
+
+    def _process_signal(
+        self,
+        signal: dict[str, Any],
+        *,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        now = self.now_factory()
+        dedup_key = _canonical_hash(
+            {
+                "correlation_key": signal["correlation_key"],
+                "sli_type": signal["sli_type"],
+            }
+        )
+        alert = self.repository.find_alert(dedup_key)
+        if signal["status"] == "recovered":
+            result = self._recover(
+                signal,
+                alert=alert,
+                dedup_key=dedup_key,
+                actor_uid=actor_uid,
+                now=now,
+            )
+        else:
+            result = self._violate(
+                signal,
+                alert=alert,
+                dedup_key=dedup_key,
+                actor_uid=actor_uid,
+                now=now,
+            )
+        return result
+
+    def _violate(
+        self,
+        signal: dict[str, Any],
+        *,
+        alert: dict[str, Any] | None,
+        dedup_key: str,
+        actor_uid: str,
+        now: datetime,
+    ) -> dict[str, Any]:
+        if alert is None or alert["status"] == "recovered":
+            alert = {
+                "uid": self.uid_factory(),
+                "dedup_key": dedup_key,
+                "incident_uid": None,
+                "layer": signal["layer"],
+                "sli_type": signal["sli_type"],
+                "title": signal["title"],
+                "severity": signal["severity"],
+                "status": "open",
+                "occurrence_count": 1,
+                "escalation_level": 1,
+                "owner_uid": signal["owner_uid"],
+                "first_observed_at": signal["observed_at"],
+                "last_observed_at": signal["observed_at"],
+                "suppressed_until": None,
+                "suppression_reason": None,
+                "delivery_status": "pending",
+                "delivery_receipt": {},
+                "evidence": signal["evidence"],
+                "created_at": now.isoformat(),
+                "updated_at": now.isoformat(),
+            }
+            alert_counter = "created_alerts"
+        else:
+            alert["occurrence_count"] = int(alert["occurrence_count"]) + 1
+            alert["escalation_level"] = min(
+                3, int(alert["escalation_level"]) + 1
+            )
+            if _severity_rank(signal["severity"]) > _severity_rank(
+                alert["severity"]
+            ):
+                alert["severity"] = signal["severity"]
+            alert["last_observed_at"] = signal["observed_at"]
+            suppressed_until = alert.get("suppressed_until")
+            if not suppressed_until or _timestamp(
+                suppressed_until, "suppressed_until"
+            ) <= now:
+                alert["status"] = "open"
+                alert["delivery_status"] = "pending"
+            alert["evidence"] = signal["evidence"]
+            alert["updated_at"] = now.isoformat()
+            alert_counter = "aggregated_alerts"
+
+        incident = self.repository.find_open_incident(dedup_key)
+        if incident is None:
+            incident_uid = self.uid_factory()
+            incident = {
+                "uid": incident_uid,
+                "code": f"INC-{now:%Y%m%d}-{incident_uid[-6:].upper()}",
+                "dedup_key": dedup_key,
+                "title": signal["title"],
+                "severity": signal["severity"],
+                "status": "open",
+                "owner_uid": signal["owner_uid"],
+                "escalation_level": alert["escalation_level"],
+                "first_detected_at": signal["observed_at"],
+                "last_observed_at": signal["observed_at"],
+                "created_by": actor_uid,
+                "created_at": now.isoformat(),
+                "updated_at": now.isoformat(),
+                "closed_by": None,
+                "closed_at": None,
+            }
+            self.repository.create_incident(incident)
+        else:
+            incident["last_observed_at"] = signal["observed_at"]
+            incident["escalation_level"] = max(
+                int(incident["escalation_level"]),
+                int(alert["escalation_level"]),
+            )
+            if _severity_rank(signal["severity"]) > _severity_rank(
+                incident["severity"]
+            ):
+                incident["severity"] = signal["severity"]
+            incident["status"] = "open"
+            incident["updated_at"] = now.isoformat()
+            self.repository.update_incident(incident)
+        alert["incident_uid"] = incident["uid"]
+        if alert_counter == "created_alerts":
+            self.repository.create_alert(alert)
+            action = "alert_created"
+        else:
+            self.repository.update_alert(alert)
+            action = "alert_aggregated"
+        self.repository.save_impacts(incident["uid"], signal["impacts"])
+        self._timeline(
+            incident["uid"],
+            action,
+            actor_uid=actor_uid,
+            evidence={
+                **signal["evidence"],
+                "source_event_key": signal["source_event_key"],
+                "alert_uid": alert["uid"],
+            },
+            now=now,
+        )
+        return {"counter": alert_counter, "incident_uid": incident["uid"]}
+
+    def _recover(
+        self,
+        signal: dict[str, Any],
+        *,
+        alert: dict[str, Any] | None,
+        dedup_key: str,
+        actor_uid: str,
+        now: datetime,
+    ) -> dict[str, Any]:
+        if alert is None or alert.get("incident_uid") is None:
+            return {"counter": "ignored", "incident_uid": None}
+        alert["status"] = "recovered"
+        alert["last_observed_at"] = signal["observed_at"]
+        alert["delivery_status"] = "recovery_pending"
+        alert["evidence"] = signal["evidence"]
+        alert["updated_at"] = now.isoformat()
+        self.repository.update_alert(alert)
+        incident = self.repository.get_incident(alert["incident_uid"])
+        if incident is None:
+            return {"counter": "ignored", "incident_uid": None}
+        incident["status"] = "monitoring"
+        incident["last_observed_at"] = signal["observed_at"]
+        incident["updated_at"] = now.isoformat()
+        self.repository.update_incident(incident)
+        self._timeline(
+            incident["uid"],
+            "recovered",
+            actor_uid=actor_uid,
+            evidence={
+                **signal["evidence"],
+                "source_event_key": signal["source_event_key"],
+                "alert_uid": alert["uid"],
+            },
+            now=now,
+        )
+        return {
+            "counter": "recovered_alerts",
+            "incident_uid": incident["uid"],
+        }
+
+    def suppress_alert(
+        self,
+        alert_uid: str,
+        *,
+        until: datetime | str,
+        reason: str,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        alert = self.repository.get_alert(_uid(alert_uid, "alert_uid"))
+        if alert is None:
+            raise LookupError("alert was not found")
+        if alert["status"] == "recovered":
+            raise RuntimeError("recovered alert cannot be suppressed")
+        deadline = _timestamp(until, "until")
+        now = self.now_factory()
+        if deadline <= now:
+            raise ValueError("suppression deadline must be in the future")
+        alert.update(
+            {
+                "status": "suppressed",
+                "suppressed_until": deadline.isoformat(),
+                "suppression_reason": _string(reason, "reason", 500),
+                "delivery_status": "suppressed",
+                "updated_at": now.isoformat(),
+            }
+        )
+        return self._save_alert_action(
+            alert,
+            action="suppressed",
+            actor_uid=actor_uid,
+            evidence={"reason": alert["suppression_reason"], "deterministic": True},
+        )
+
+    def acknowledge_delivery(
+        self,
+        alert_uid: str,
+        *,
+        channel: str,
+        receipt_id: str,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        alert = self.repository.get_alert(_uid(alert_uid, "alert_uid"))
+        if alert is None:
+            raise LookupError("alert was not found")
+        now = self.now_factory()
+        alert.update(
+            {
+                "delivery_status": "acknowledged",
+                "delivery_receipt": {
+                    "channel": _string(channel, "channel", 80),
+                    "receipt_id": _string(receipt_id, "receipt_id", 200),
+                    "acknowledged_by": _uid(actor_uid, "actor_uid"),
+                    "acknowledged_at": now.isoformat(),
+                },
+                "updated_at": now.isoformat(),
+            }
+        )
+        return self._save_alert_action(
+            alert,
+            action="delivery_acknowledged",
+            actor_uid=actor_uid,
+            evidence={
+                "receipt_id": alert["delivery_receipt"]["receipt_id"],
+                "deterministic": True,
+            },
+        )
+
+    def _save_alert_action(
+        self,
+        alert: dict[str, Any],
+        *,
+        action: str,
+        actor_uid: str,
+        evidence: dict[str, Any],
+    ) -> dict[str, Any]:
+        try:
+            stored = self.repository.update_alert(alert)
+            self._timeline(
+                alert["incident_uid"],
+                action,
+                actor_uid=_uid(actor_uid, "actor_uid"),
+                evidence=evidence,
+                now=self.now_factory(),
+            )
+            self.commit()
+            return stored
+        except Exception:
+            self.rollback()
+            raise
+
+    def escalate_incident(
+        self,
+        incident_uid: str,
+        *,
+        reason: str,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(incident_uid, "incident_uid")
+        incident = self.repository.get_incident(uid)
+        if incident is None:
+            raise LookupError("incident was not found")
+        if incident["status"] == "closed":
+            raise RuntimeError("closed incident cannot be escalated")
+        incident["escalation_level"] = min(
+            3, int(incident["escalation_level"]) + 1
+        )
+        incident["updated_at"] = self.now_factory().isoformat()
+        try:
+            stored = self.repository.update_incident(incident)
+            self._timeline(
+                uid,
+                "escalated",
+                actor_uid=_uid(actor_uid, "actor_uid"),
+                evidence={
+                    "reason": _string(reason, "reason", 500),
+                    "level": incident["escalation_level"],
+                    "deterministic": True,
+                },
+                now=self.now_factory(),
+            )
+            self.commit()
+            return stored
+        except Exception:
+            self.rollback()
+            raise
+
+    def close_incident(
+        self,
+        incident_uid: str,
+        payload: Any,
+        *,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(incident_uid, "incident_uid")
+        body = _closed_object(
+            payload,
+            {
+                "root_cause",
+                "user_impact",
+                "corrective_actions",
+                "closure_evidence",
+            },
+            "incident postmortem",
+        )
+        required = {
+            "root_cause",
+            "user_impact",
+            "corrective_actions",
+            "closure_evidence",
+        }
+        if set(body) != required:
+            raise ValueError("incident postmortem evidence is incomplete")
+        detail = self.repository.incident_detail(uid)
+        if detail is None:
+            raise LookupError("incident was not found")
+        if detail["status"] == "closed":
+            return detail
+        if not detail.get("owner_uid") or not detail.get("impacts"):
+            raise RuntimeError("incident responsibility and impact are required")
+        if not detail.get("alerts") or any(
+            item["status"] != "recovered" for item in detail["alerts"]
+        ):
+            raise RuntimeError("all incident alerts must recover before closure")
+        actions = body["corrective_actions"]
+        evidence = body["closure_evidence"]
+        if (
+            not isinstance(actions, list)
+            or not actions
+            or not isinstance(evidence, list)
+            or not evidence
+        ):
+            raise ValueError("incident postmortem evidence is incomplete")
+        normalized_actions = [
+            _string(item, "corrective action", 500) for item in actions
+        ]
+        normalized_evidence = []
+        for item in evidence:
+            value = _closed_object(
+                item, {"kind", "ref"}, "closure evidence"
+            )
+            normalized_evidence.append(
+                {
+                    "kind": _string(value.get("kind"), "evidence kind", 80),
+                    "ref": _string(value.get("ref"), "evidence ref", 500),
+                }
+            )
+        actor = _uid(actor_uid, "actor_uid")
+        now = self.now_factory()
+        postmortem = {
+            "uid": self.uid_factory(),
+            "incident_uid": uid,
+            "root_cause": _string(
+                body.get("root_cause"), "root_cause", 2_000
+            ),
+            "user_impact": _string(
+                body.get("user_impact"), "user_impact", 2_000
+            ),
+            "corrective_actions": normalized_actions,
+            "closure_evidence": normalized_evidence,
+            "created_by": actor,
+            "created_at": now.isoformat(),
+        }
+        incident = self.repository.get_incident(uid)
+        incident.update(
+            {
+                "status": "closed",
+                "closed_by": actor,
+                "closed_at": now.isoformat(),
+                "updated_at": now.isoformat(),
+            }
+        )
+        try:
+            self.repository.save_postmortem(postmortem)
+            self.repository.update_incident(incident)
+            self._timeline(
+                uid,
+                "closed",
+                actor_uid=actor,
+                evidence={
+                    "postmortem_uid": postmortem["uid"],
+                    "closure_evidence_count": len(normalized_evidence),
+                    "deterministic": True,
+                },
+                now=now,
+            )
+            self.commit()
+            return self.repository.incident_detail(uid)
+        except Exception:
+            self.rollback()
+            raise
+
+    def _timeline(
+        self,
+        incident_uid: str,
+        action: str,
+        *,
+        actor_uid: str,
+        evidence: dict[str, Any],
+        now: datetime,
+    ) -> None:
+        _reject_secret_material(evidence)
+        self.repository.append_timeline(
+            {
+                "uid": self.uid_factory(),
+                "incident_uid": incident_uid,
+                "action": action,
+                "actor_uid": actor_uid,
+                "evidence": evidence,
+                "created_at": now.isoformat(),
+            }
+        )
+
+    def list_alerts(self) -> list[dict[str, Any]]:
+        return self.repository.list_alerts()
+
+    def list_incidents(self) -> list[dict[str, Any]]:
+        return self.repository.list_incidents()
+
+    def incident_detail(self, uid: str) -> dict[str, Any]:
+        value = self.repository.incident_detail(_uid(uid, "incident_uid"))
+        if value is None:
+            raise LookupError("incident was not found")
+        return value
+
+    def overview(self) -> dict[str, Any]:
+        counts = self.repository.overview_counts()
+        return {
+            "layers": {layer: dict(counts.get(layer) or {}) for layer in LAYERS},
+            "sli_catalog": self.sli_catalog(),
+            "slos": self.list_slos(),
+            "open_incident_count": sum(
+                item["status"] != "closed" for item in self.list_incidents()
+            ),
+        }

+ 759 - 0
deployment/app/core/events/data_observability_repository.py

@@ -0,0 +1,759 @@
+"""PostgreSQL persistence and canonical signal collection for data incidents."""
+
+from __future__ import annotations
+
+import json
+import uuid
+from typing import Any
+
+from sqlalchemy import text
+
+
+def _json(value: Any) -> str:
+    return json.dumps(value, ensure_ascii=False, sort_keys=True)
+
+
+def _row(value):
+    if value is None:
+        return None
+    result = dict(value)
+    for key, item in tuple(result.items()):
+        if isinstance(item, uuid.UUID):
+            result[key] = str(item)
+        elif hasattr(item, "isoformat"):
+            result[key] = item.isoformat()
+        elif isinstance(item, dict):
+            result[key] = dict(item)
+    return result
+
+
+class SqlAlchemyDataObservabilityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def create_slo(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_slo_policies (
+                    uid, code, name, sli_type, scope_type, scope_uid,
+                    operator, target, window_seconds, owner_uid, status,
+                    created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :name, :sli_type,
+                    :scope_type, :scope_uid, :operator, :target,
+                    :window_seconds, CAST(:owner_uid AS uuid), :status,
+                    CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                """
+            ),
+            record,
+        )
+        return self.get_slo(record["uid"])
+
+    def get_slo(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, code, name, sli_type, scope_type, scope_uid,
+                           operator, target, window_seconds, owner_uid, status,
+                           created_by, created_at, updated_at
+                    FROM public.data_slo_policies
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def list_slos(self):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, code, name, sli_type, scope_type, scope_uid,
+                           operator, target, window_seconds, owner_uid, status,
+                           created_by, created_at, updated_at
+                    FROM public.data_slo_policies
+                    ORDER BY status, sli_type, code
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        ]
+
+    def pending_signals(self):
+        signals = self._pending_quality_signals()
+        signals.extend(self._pending_metadata_signals())
+        return sorted(
+            signals,
+            key=lambda item: (item["observed_at"], item["source_event_key"]),
+        )
+
+    def _pending_quality_signals(self):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT event.uid, event.run_uid, event.asset_uid,
+                       event.sla_type, event.status, event.severity,
+                       event.actual, event.threshold, event.owner_uid,
+                       event.escalation_level, event.evidence,
+                       event.created_at, run.business_domain_uid,
+                       asset.namespace, asset.name, asset.snapshot,
+                       product.id AS product_id,
+                       product.product_name
+                FROM public.quality_sla_events event
+                JOIN public.quality_profile_runs run ON run.uid = event.run_uid
+                JOIN public.active_metadata_assets asset
+                  ON asset.uid = event.asset_uid
+                LEFT JOIN public.data_products product
+                  ON product.target_schema = asset.namespace
+                 AND product.target_table = asset.name
+                WHERE event.status IN ('violated','recovered')
+                  AND NOT EXISTS (
+                    SELECT 1
+                    FROM public.data_observability_source_events consumed
+                    WHERE consumed.source_event_key =
+                        'quality-sla:' || event.uid::text || ':' || event.status
+                  )
+                ORDER BY event.created_at, event.uid
+                """
+            )
+        ).mappings()
+        results = []
+        for raw in rows:
+            row = _row(raw)
+            status = (
+                "violated"
+                if row["status"] == "violated"
+                else "recovered"
+            )
+            snapshot = row.get("snapshot") or {}
+            impacts = [
+                {
+                    "target_type": "asset",
+                    "target_uid": row["asset_uid"],
+                    "label": f"{row['namespace']}.{row['name']}",
+                }
+            ]
+            domain_uid = (
+                row.get("business_domain_uid")
+                or snapshot.get("business_domain_uid")
+            )
+            if domain_uid:
+                impacts.append(
+                    {
+                        "target_type": "business_domain",
+                        "target_uid": str(domain_uid),
+                        "label": str(
+                            snapshot.get("business_domain_name") or domain_uid
+                        ),
+                    }
+                )
+            if row.get("product_id") is not None:
+                impacts.append(
+                    {
+                        "target_type": "data_product",
+                        "target_uid": str(row["product_id"]),
+                        "label": str(row["product_name"]),
+                    }
+                )
+            if snapshot.get("user_impact_group_uid"):
+                impacts.append(
+                    {
+                        "target_type": "user_group",
+                        "target_uid": str(
+                            snapshot["user_impact_group_uid"]
+                        ),
+                        "label": str(
+                            snapshot.get("user_impact_group_name")
+                            or snapshot["user_impact_group_uid"]
+                        ),
+                    }
+                )
+            sli_type = (
+                "freshness"
+                if row["sla_type"] == "freshness"
+                else "quality"
+            )
+            results.append(
+                {
+                    "source_event_key": (
+                        f"quality-sla:{row['uid']}:{status}"
+                    ),
+                    "source_type": "quality_sla_event",
+                    "source_uid": row["uid"],
+                    "correlation_key": (
+                        f"quality-sla:{row['asset_uid']}:{row['sla_type']}"
+                    ),
+                    "layer": "data",
+                    "sli_type": sli_type,
+                    "status": status,
+                    "severity": (
+                        row["severity"] if status == "violated" else "info"
+                    ),
+                    "title": (
+                        f"{row['namespace']}.{row['name']} "
+                        f"{row['sla_type']} SLA "
+                        f"{'违约' if status == 'violated' else '恢复'}"
+                    ),
+                    "owner_uid": row["owner_uid"],
+                    "observed_at": row["created_at"],
+                    "actual": float(row["actual"]),
+                    "target": float(row["threshold"]),
+                    "evidence": {
+                        **(row.get("evidence") or {}),
+                        "quality_sla_event_uid": row["uid"],
+                        "run_uid": row["run_uid"],
+                        "deterministic": True,
+                    },
+                    "impacts": impacts,
+                }
+            )
+        return results
+
+    def _pending_metadata_signals(self):
+        rows = self.session.execute(
+            text(
+                """
+                SELECT run.uid, run.plan_uid, run.batch_key, run.status,
+                       run.attempt_count, run.failure_code, run.failure_reason,
+                       run.statistics, run.finished_at, plan.name,
+                       plan.owner_uid, plan.scope, plan.source_uid
+                FROM public.active_metadata_runs run
+                JOIN public.active_metadata_plans plan
+                  ON plan.uid = run.plan_uid
+                WHERE (
+                    run.status = 'failed'
+                    OR (
+                        run.status = 'completed'
+                        AND (
+                            SELECT previous_run.status
+                            FROM public.active_metadata_runs previous_run
+                            WHERE previous_run.plan_uid = run.plan_uid
+                              AND (
+                                previous_run.finished_at,
+                                previous_run.uid
+                              ) < (run.finished_at, run.uid)
+                            ORDER BY previous_run.finished_at DESC,
+                                     previous_run.uid DESC
+                            LIMIT 1
+                        ) = 'failed'
+                    )
+                )
+                  AND NOT EXISTS (
+                    SELECT 1
+                    FROM public.data_observability_source_events consumed
+                    WHERE consumed.source_event_key =
+                        'active-metadata:' || run.plan_uid::text || ':' ||
+                        run.uid::text || ':' ||
+                        CASE WHEN run.status = 'failed'
+                             THEN 'violated' ELSE 'recovered' END
+                  )
+                ORDER BY run.finished_at, run.uid
+                """
+            )
+        ).mappings()
+        results = []
+        for raw in rows:
+            row = _row(raw)
+            scope = row.get("scope") or {}
+            status = (
+                "violated"
+                if row["status"] == "failed"
+                else "recovered"
+            )
+            impacts = [
+                {
+                    "target_type": "service",
+                    "target_uid": f"active-metadata-plan:{row['plan_uid']}",
+                    "label": str(row["name"]),
+                }
+            ]
+            bindings = (
+                ("business_domain", "business_domain_uid", "business_domain_name"),
+                ("data_product", "data_product_uid", "data_product_name"),
+                ("user_group", "user_impact_group_uid", "user_impact_group_name"),
+            )
+            for target_type, uid_key, label_key in bindings:
+                if scope.get(uid_key):
+                    impacts.append(
+                        {
+                            "target_type": target_type,
+                            "target_uid": str(scope[uid_key]),
+                            "label": str(
+                                scope.get(label_key) or scope[uid_key]
+                            ),
+                        }
+                    )
+            results.append(
+                {
+                    "source_event_key": (
+                        f"active-metadata:{row['plan_uid']}:"
+                        f"{row['uid']}:{status}"
+                    ),
+                    "source_type": "active_metadata_run",
+                    "source_uid": row["uid"],
+                    "correlation_key": (
+                        f"active-metadata-plan:{row['plan_uid']}"
+                    ),
+                    "layer": "task",
+                    "sli_type": "delivery",
+                    "status": status,
+                    "severity": "critical" if status == "violated" else "info",
+                    "title": (
+                        f"{row['name']}采集"
+                        f"{'失败' if status == 'violated' else '恢复'}"
+                    ),
+                    "owner_uid": row["owner_uid"],
+                    "observed_at": row["finished_at"],
+                    "actual": 0 if status == "violated" else 1,
+                    "target": 1,
+                    "evidence": {
+                        "run_uid": row["uid"],
+                        "plan_uid": row["plan_uid"],
+                        "source_uid": row["source_uid"],
+                        "batch_key": row["batch_key"],
+                        "attempt_count": row["attempt_count"],
+                        "failure_code": row.get("failure_code"),
+                        "failure_reason": row.get("failure_reason"),
+                        "statistics": row.get("statistics") or {},
+                        "deterministic": True,
+                    },
+                    "impacts": impacts,
+                }
+            )
+        return results
+
+    def source_event_exists(self, source_event_key):
+        return bool(
+            self.session.execute(
+                text(
+                    """
+                    SELECT 1
+                    FROM public.data_observability_source_events
+                    WHERE source_event_key = :source_event_key
+                    """
+                ),
+                {"source_event_key": source_event_key},
+            ).scalar()
+        )
+
+    def record_source_event(self, record):
+        result = self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_observability_source_events (
+                    uid, source_event_key, source_type, source_uid, status,
+                    signal, processed_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :source_event_key, :source_type,
+                    :source_uid, :status, CAST(:signal AS jsonb),
+                    CAST(:processed_at AS timestamptz)
+                ) ON CONFLICT (source_event_key) DO NOTHING
+                """
+            ),
+            {**record, "signal": _json(record["signal"])},
+        )
+        return result.rowcount == 1
+
+    def find_alert(self, dedup_key):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT *
+                    FROM public.data_observability_alerts
+                    WHERE dedup_key = :dedup_key
+                    ORDER BY last_observed_at DESC, uid DESC
+                    LIMIT 1
+                    """
+                ),
+                {"dedup_key": dedup_key},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def get_alert(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT *
+                    FROM public.data_observability_alerts
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def create_alert(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_observability_alerts (
+                    uid, dedup_key, incident_uid, layer, sli_type, title,
+                    severity, status, occurrence_count, escalation_level,
+                    owner_uid, first_observed_at, last_observed_at,
+                    suppressed_until, suppression_reason, delivery_status,
+                    delivery_receipt, evidence, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :dedup_key,
+                    CAST(:incident_uid AS uuid), :layer, :sli_type, :title,
+                    :severity, :status, :occurrence_count, :escalation_level,
+                    CAST(:owner_uid AS uuid),
+                    CAST(:first_observed_at AS timestamptz),
+                    CAST(:last_observed_at AS timestamptz),
+                    CAST(:suppressed_until AS timestamptz),
+                    :suppression_reason, :delivery_status,
+                    CAST(:delivery_receipt AS jsonb), CAST(:evidence AS jsonb),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **record,
+                "delivery_receipt": _json(record["delivery_receipt"]),
+                "evidence": _json(record["evidence"]),
+            },
+        )
+        return self.get_alert(record["uid"])
+
+    def update_alert(self, record):
+        self.session.execute(
+            text(
+                """
+                UPDATE public.data_observability_alerts
+                SET severity = :severity, status = :status,
+                    occurrence_count = :occurrence_count,
+                    escalation_level = :escalation_level,
+                    last_observed_at = CAST(:last_observed_at AS timestamptz),
+                    suppressed_until = CAST(:suppressed_until AS timestamptz),
+                    suppression_reason = :suppression_reason,
+                    delivery_status = :delivery_status,
+                    delivery_receipt = CAST(:delivery_receipt AS jsonb),
+                    evidence = CAST(:evidence AS jsonb),
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                """
+            ),
+            {
+                **record,
+                "delivery_receipt": _json(record["delivery_receipt"]),
+                "evidence": _json(record["evidence"]),
+            },
+        )
+        return self.get_alert(record["uid"])
+
+    def find_open_incident(self, dedup_key):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT *
+                    FROM public.data_incidents
+                    WHERE dedup_key = :dedup_key AND status <> 'closed'
+                    ORDER BY created_at DESC LIMIT 1
+                    """
+                ),
+                {"dedup_key": dedup_key},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def get_incident(self, uid):
+        return _row(
+            self.session.execute(
+                text("SELECT * FROM public.data_incidents WHERE uid = CAST(:uid AS uuid)"),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def create_incident(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_incidents (
+                    uid, code, dedup_key, title, severity, status, owner_uid,
+                    escalation_level, first_detected_at, last_observed_at,
+                    created_by, created_at, updated_at, closed_by, closed_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :dedup_key, :title, :severity,
+                    :status, CAST(:owner_uid AS uuid), :escalation_level,
+                    CAST(:first_detected_at AS timestamptz),
+                    CAST(:last_observed_at AS timestamptz),
+                    CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz),
+                    CAST(:closed_by AS uuid), CAST(:closed_at AS timestamptz)
+                )
+                """
+            ),
+            record,
+        )
+        return self.get_incident(record["uid"])
+
+    def update_incident(self, record):
+        self.session.execute(
+            text(
+                """
+                UPDATE public.data_incidents
+                SET severity = :severity, status = :status,
+                    owner_uid = CAST(:owner_uid AS uuid),
+                    escalation_level = :escalation_level,
+                    last_observed_at = CAST(:last_observed_at AS timestamptz),
+                    updated_at = CAST(:updated_at AS timestamptz),
+                    closed_by = CAST(:closed_by AS uuid),
+                    closed_at = CAST(:closed_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                """
+            ),
+            record,
+        )
+        return self.get_incident(record["uid"])
+
+    def save_impacts(self, incident_uid, impacts):
+        for impact in impacts:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.data_incident_impacts (
+                        incident_uid, target_type, target_uid, label
+                    ) VALUES (
+                        CAST(:incident_uid AS uuid), :target_type,
+                        :target_uid, :label
+                    ) ON CONFLICT (
+                        incident_uid, target_type, target_uid
+                    ) DO UPDATE SET label = EXCLUDED.label
+                    """
+                ),
+                {"incident_uid": incident_uid, **impact},
+            )
+
+    def append_timeline(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_incident_timeline (
+                    uid, incident_uid, action, actor_uid, evidence, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:incident_uid AS uuid), :action,
+                    CAST(:actor_uid AS uuid), CAST(:evidence AS jsonb),
+                    CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {**record, "evidence": _json(record["evidence"])},
+        )
+
+    def save_postmortem(self, record):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_incident_postmortems (
+                    uid, incident_uid, root_cause, user_impact,
+                    corrective_actions, closure_evidence, created_by,
+                    created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:incident_uid AS uuid),
+                    :root_cause, :user_impact,
+                    CAST(:corrective_actions AS jsonb),
+                    CAST(:closure_evidence AS jsonb),
+                    CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **record,
+                "corrective_actions": _json(record["corrective_actions"]),
+                "closure_evidence": _json(record["closure_evidence"]),
+            },
+        )
+
+    def list_alerts(self):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT * FROM public.data_observability_alerts
+                    ORDER BY status, severity DESC, last_observed_at DESC
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_incidents(self):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT * FROM public.data_incidents
+                    ORDER BY status, severity DESC, updated_at DESC
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        ]
+
+    def incident_detail(self, uid):
+        incident = self.get_incident(uid)
+        if incident is None:
+            return None
+        alerts = [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT * FROM public.data_observability_alerts
+                    WHERE incident_uid = CAST(:uid AS uuid)
+                    ORDER BY first_observed_at, uid
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+        impacts = [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT target_type, target_uid, label, created_at
+                    FROM public.data_incident_impacts
+                    WHERE incident_uid = CAST(:uid AS uuid)
+                    ORDER BY target_type, target_uid
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+        timeline = [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, incident_uid, action, actor_uid, evidence,
+                           created_at
+                    FROM public.data_incident_timeline
+                    WHERE incident_uid = CAST(:uid AS uuid)
+                    ORDER BY created_at, uid
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+        postmortem = _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, incident_uid, root_cause, user_impact,
+                           corrective_actions, closure_evidence, created_by,
+                           created_at
+                    FROM public.data_incident_postmortems
+                    WHERE incident_uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return {
+            **incident,
+            "alerts": alerts,
+            "impacts": impacts,
+            "timeline": timeline,
+            "postmortem": postmortem,
+        }
+
+    def overview_counts(self):
+        result = {
+            layer: {"healthy": 0, "warning": 0, "critical": 0}
+            for layer in ("service", "task", "data", "capacity")
+        }
+        rows = self.session.execute(
+            text(
+                """
+                SELECT layer,
+                       COUNT(*) FILTER (
+                           WHERE status = 'recovered'
+                       ) AS healthy,
+                       COUNT(*) FILTER (
+                           WHERE status <> 'recovered'
+                             AND severity IN ('info','warning')
+                       ) AS warning,
+                       COUNT(*) FILTER (
+                           WHERE status <> 'recovered'
+                             AND severity IN ('error','critical')
+                       ) AS critical
+                FROM public.data_observability_alerts
+                GROUP BY layer
+                """
+            )
+        ).mappings()
+        for row in rows:
+            result[row["layer"]] = {
+                "healthy": int(row["healthy"] or 0),
+                "warning": int(row["warning"] or 0),
+                "critical": int(row["critical"] or 0),
+            }
+        service = self.session.execute(
+            text(
+                """
+                SELECT
+                    COUNT(*) FILTER (
+                        WHERE status IN ('pending','processing')
+                    ) AS backlog,
+                    COUNT(*) FILTER (
+                        WHERE status IN ('failed','dead_letter')
+                    ) AS failed
+                FROM public.outbox_events
+                """
+            )
+        ).mappings().one()
+        backlog = int(service["backlog"] or 0)
+        failed = int(service["failed"] or 0)
+        result["service"] = {
+            "healthy": int(backlog == 0 and failed == 0),
+            "warning": int(backlog > 0 and failed == 0),
+            "critical": int(failed > 0),
+            "evidence": {"outbox_backlog": backlog, "outbox_failed": failed},
+        }
+        database_size = int(
+            self.session.execute(
+                text("SELECT pg_database_size(current_database())")
+            ).scalar()
+            or 0
+        )
+        result["capacity"]["unknown"] = 1
+        result["capacity"]["evidence"] = {
+            "database_size_bytes": database_size,
+            "capacity_limit": "not_configured",
+        }
+        return result

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

@@ -55,6 +55,9 @@ ACTIVE_METADATA_MANAGE = "active-metadata:manage"
 SEMANTIC_GOVERNANCE_EDIT = "semantic-governance:edit"
 SEMANTIC_GOVERNANCE_REVIEW = "semantic-governance:review"
 SEMANTIC_GOVERNANCE_PUBLISH = "semantic-governance:publish"
+DATA_OBSERVABILITY_READ = "data-observability:read"
+DATA_OBSERVABILITY_OPERATE = "data-observability:operate"
+DATA_OBSERVABILITY_MANAGE = "data-observability:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -64,6 +67,7 @@ ROLE_PERMISSIONS = {
             RESPONSIBILITIES_READ,
             DOMAIN_TEMPLATES_READ,
             ACTIVE_METADATA_READ,
+            DATA_OBSERVABILITY_READ,
         }
     ),
     "editor": frozenset(
@@ -91,6 +95,8 @@ ROLE_PERMISSIONS = {
             ACTIVE_METADATA_READ,
             ACTIVE_METADATA_OPERATE,
             SEMANTIC_GOVERNANCE_EDIT,
+            DATA_OBSERVABILITY_READ,
+            DATA_OBSERVABILITY_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -143,6 +149,9 @@ ROLE_PERMISSIONS = {
             SEMANTIC_GOVERNANCE_EDIT,
             SEMANTIC_GOVERNANCE_REVIEW,
             SEMANTIC_GOVERNANCE_PUBLISH,
+            DATA_OBSERVABILITY_READ,
+            DATA_OBSERVABILITY_OPERATE,
+            DATA_OBSERVABILITY_MANAGE,
         }
     ),
 }
@@ -175,6 +184,12 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path == "/api/meta/active-metadata/plans":
             return (ACTIVE_METADATA_MANAGE,)
         return (ACTIVE_METADATA_OPERATE,)
+    if path.startswith("/api/datafactory/observability"):
+        if method == "GET":
+            return (DATA_OBSERVABILITY_READ,)
+        if path == "/api/datafactory/observability/slos":
+            return (DATA_OBSERVABILITY_MANAGE,)
+        return (DATA_OBSERVABILITY_OPERATE,)
     if path in {"/api/knowledge/search", "/api/knowledge/ask"}:
         return (READ_GOVERNANCE,)
     if path.startswith("/api/rules"):

+ 183 - 0
deployment/migrations/versions/20260731_410_data_observability.py

@@ -0,0 +1,183 @@
+"""Add canonical SLO policies, alerts and evidence-bound data incidents."""
+
+from alembic import op
+
+revision = "20260731_410"
+down_revision = "20260731_400"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.data_slo_policies (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            sli_type VARCHAR(30) NOT NULL CHECK (
+                sli_type IN ('freshness','completeness','quality','delivery')
+            ),
+            scope_type VARCHAR(40) NOT NULL,
+            scope_uid VARCHAR(500) NOT NULL,
+            operator VARCHAR(2) NOT NULL CHECK (operator IN ('>=','<=')),
+            target NUMERIC NOT NULL,
+            window_seconds INTEGER NOT NULL CHECK (
+                window_seconds BETWEEN 60 AND 31536000
+            ),
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('active','retired')
+            ),
+            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,
+            UNIQUE (sli_type, scope_type, scope_uid)
+        );
+
+        CREATE TABLE public.data_incidents (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(40) NOT NULL UNIQUE,
+            dedup_key CHAR(64) NOT NULL,
+            title VARCHAR(300) NOT NULL,
+            severity VARCHAR(20) NOT NULL CHECK (
+                severity IN ('info','warning','error','critical')
+            ),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('open','monitoring','closed')
+            ),
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            escalation_level INTEGER NOT NULL CHECK (
+                escalation_level BETWEEN 1 AND 3
+            ),
+            first_detected_at TIMESTAMPTZ NOT NULL,
+            last_observed_at TIMESTAMPTZ NOT NULL,
+            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,
+            closed_by UUID REFERENCES public.users(id),
+            closed_at TIMESTAMPTZ
+        );
+
+        CREATE UNIQUE INDEX uq_data_incident_open_dedup
+            ON public.data_incidents(dedup_key)
+            WHERE status <> 'closed';
+
+        CREATE TABLE public.data_observability_alerts (
+            uid UUID PRIMARY KEY,
+            dedup_key CHAR(64) NOT NULL,
+            incident_uid UUID NOT NULL REFERENCES public.data_incidents(uid),
+            layer VARCHAR(20) NOT NULL CHECK (
+                layer IN ('service','task','data','capacity')
+            ),
+            sli_type VARCHAR(30) NOT NULL CHECK (
+                sli_type IN ('freshness','completeness','quality','delivery')
+            ),
+            title VARCHAR(300) NOT NULL,
+            severity VARCHAR(20) NOT NULL CHECK (
+                severity IN ('info','warning','error','critical')
+            ),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('open','suppressed','recovered')
+            ),
+            occurrence_count INTEGER NOT NULL CHECK (occurrence_count > 0),
+            escalation_level INTEGER NOT NULL CHECK (
+                escalation_level BETWEEN 1 AND 3
+            ),
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            first_observed_at TIMESTAMPTZ NOT NULL,
+            last_observed_at TIMESTAMPTZ NOT NULL,
+            suppressed_until TIMESTAMPTZ,
+            suppression_reason VARCHAR(500),
+            delivery_status VARCHAR(30) NOT NULL CHECK (
+                delivery_status IN (
+                    'pending','suppressed','acknowledged','recovery_pending'
+                )
+            ),
+            delivery_receipt JSONB NOT NULL DEFAULT '{}'::jsonb,
+            evidence JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(delivery_receipt) = 'object'),
+            CHECK (jsonb_typeof(evidence) = 'object')
+        );
+
+        CREATE UNIQUE INDEX uq_data_alert_open_dedup
+            ON public.data_observability_alerts(dedup_key)
+            WHERE status <> 'recovered';
+
+        CREATE TABLE public.data_observability_source_events (
+            uid UUID PRIMARY KEY,
+            source_event_key VARCHAR(500) NOT NULL UNIQUE,
+            source_type VARCHAR(80) NOT NULL,
+            source_uid VARCHAR(500) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('violated','recovered')
+            ),
+            signal JSONB NOT NULL,
+            processed_at TIMESTAMPTZ NOT NULL,
+            CHECK (jsonb_typeof(signal) = 'object')
+        );
+
+        CREATE TABLE public.data_incident_impacts (
+            uid BIGSERIAL PRIMARY KEY,
+            incident_uid UUID NOT NULL
+                REFERENCES public.data_incidents(uid) ON DELETE CASCADE,
+            target_type VARCHAR(40) NOT NULL CHECK (
+                target_type IN (
+                    'asset','data_product','business_domain',
+                    'user_group','service'
+                )
+            ),
+            target_uid VARCHAR(500) NOT NULL,
+            label VARCHAR(300) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (incident_uid, target_type, target_uid)
+        );
+
+        CREATE TABLE public.data_incident_timeline (
+            uid UUID PRIMARY KEY,
+            incident_uid UUID NOT NULL
+                REFERENCES public.data_incidents(uid) ON DELETE CASCADE,
+            action VARCHAR(40) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            evidence JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(evidence) = 'object')
+        );
+
+        CREATE TABLE public.data_incident_postmortems (
+            uid UUID PRIMARY KEY,
+            incident_uid UUID NOT NULL UNIQUE
+                REFERENCES public.data_incidents(uid) ON DELETE CASCADE,
+            root_cause TEXT NOT NULL,
+            user_impact TEXT NOT NULL,
+            corrective_actions JSONB NOT NULL,
+            closure_evidence JSONB NOT NULL,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(corrective_actions) = 'array'),
+            CHECK (jsonb_array_length(corrective_actions) > 0),
+            CHECK (jsonb_typeof(closure_evidence) = 'array'),
+            CHECK (jsonb_array_length(closure_evidence) > 0)
+        );
+
+        CREATE INDEX idx_data_alert_layer_status
+            ON public.data_observability_alerts(
+                layer, status, severity, last_observed_at DESC
+            );
+        CREATE INDEX idx_data_incident_status_updated
+            ON public.data_incidents(status, updated_at DESC);
+        CREATE INDEX idx_data_incident_timeline_created
+            ON public.data_incident_timeline(
+                incident_uid, created_at, uid
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "data incident evidence is append-only; "
+        "downgrade requires an approved archival migration"
+    )

+ 11 - 5
docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md

@@ -299,11 +299,11 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 
 **主要工作:**
 
-- [ ] 定义新鲜度、完整性、质量和交付 SLI/SLO。
-- [ ] 聚合重复告警,支持抑制、升级、送达回执和恢复事件。
-- [ ] 建立事故、责任人、影响资产、处置时间线和复盘。
-- [ ] 将技术异常映射到数据产品、业务域和用户影响。
-- [ ] 建立平台服务、任务、数据和容量的统一观测视图。
+- [x] 定义新鲜度、完整性、质量和交付 SLI/SLO。
+- [x] 聚合重复告警,支持抑制、升级、送达回执和恢复事件。
+- [x] 建立事故、责任人、影响资产、处置时间线和复盘。
+- [x] 将技术异常映射到数据产品、业务域和用户影响。
+- [x] 建立平台服务、任务、数据和容量的统一观测视图。
 
 **主要文件区域:**
 
@@ -317,6 +317,12 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 **完成门禁:** 至少完成一次采集失败、一次质量 SLA 违约和一次恢复场景;每个事故
 都有告警、影响、责任、处置和关闭证据。
 
+**工程状态:** 已完成本地工程门禁。主动元数据采集失败/恢复和通用质量 SLA
+违约/恢复已通过 PostgreSQL 集成验证;重复事件可聚合,事故可下钻到责任人、
+资产、数据产品、业务域、用户群、处置时间线、复盘和关闭证据。容量上限、
+企业告警渠道、正式责任人与影响绑定仍须在 P2-WP12、P2-WP13 验收,当前不
+等同生产就绪。
+
 ### P2-WP06 统一责任体系
 
 **目标:** 将设备责任矩阵扩展到资产、术语、标准、质量、产品和 Agent。

+ 23 - 0
docs/architecture/DATA_MODEL.md

@@ -353,6 +353,28 @@ embedding profile 与模型、维度一致时才同步 PostgreSQL canonical 治
 canonical 数据。画像与异常只用于数据运营与治理,不提供任意 SQL、在线分析、BI 计算或
 自动修复。
 
+## 4.7 P2-WP05 数据可观测与事故
+
+统一事故层不复制质量、采集或设备证据。`active_metadata_runs` 和
+`quality_sla_events` 仍是运行状态与质量 SLA 的权威记录;事故服务以稳定源事件键在同一
+事务中登记消费、聚合告警并追加事故证据。并发采集通过源事件唯一键和未关闭事故/告警的
+部分唯一索引去重。
+
+| 数据对象 | 作用 | 关键约束 |
+|---|---|---|
+| `data_slo_policies` | 新鲜度、完整性、质量和交付目标 | 范围内 SLI 唯一;保存目标、比较符、统计窗口和责任人 |
+| `data_observability_source_events` | 权威源事件消费账本 | 源事件键唯一;信号、状态和处理时间与事故写入同一事务 |
+| `data_observability_alerts` | 聚合告警当前态 | 未恢复告警按相关键唯一;保存重复次数、抑制、升级和送达回执 |
+| `data_incidents` | 数据事故当前态 | 未关闭事故按相关键唯一;显式责任人、严重度和恢复观察状态 |
+| `data_incident_impacts` | 技术异常影响映射 | 只接受资产、数据产品、业务域、用户群和服务五类已绑定对象 |
+| `data_incident_timeline` | 不可变处置时间线 | 告警建立/聚合、抑制、回执、升级、恢复和关闭均追加证据 |
+| `data_incident_postmortems` | 事故复盘和关闭证据 | 每个事故最多一份;根因、用户影响、纠正措施和关闭证据均必填 |
+
+恢复事件只将事故推进到恢复观察,不自动关闭。只有全部关联告警已经恢复,且事故具有责任、
+影响、复盘和关闭证据时才允许关闭。根因由运营人员基于保存证据确认,平台不以 AI 推测替代。
+服务视图读取 outbox 积压/失败,任务与数据视图读取事故告警;容量只展示数据库当前用量,
+企业容量上限未配置时返回未知,不填造健康结论。
+
 ## 5. 所有权与删除规则
 
 - PostgreSQL 是身份、权限、映射、任务状态、布局和一致性事件的源真相。
@@ -373,6 +395,7 @@ canonical 数据。画像与异常只用于数据运营与治理,不提供任
 - 主动元数据计划、批次、资产当前态、不可变版本、变化候选、字段血缘、健康信号和纠错审计以 PostgreSQL 为源真相;现有目录快照继续作为批次输入证据。删除只形成候选,解析失败保留原因,发现或纠错不能绕过既有发布门禁覆盖 Neo4j 已发布元数据。
 - 业务术语、通用代码集、指标口径、物理字段映射、不可变版本、独立审批和发布审计以 PostgreSQL 为源真相;标准版本继续复用既有不可变发布门禁。指标口径不是执行计划,发布后通过 outbox 同步 canonical 治理知识,知识同步配置未就绪时事件不得丢失。
 - 通用质量模板、版本、画像批次、字段指标、异常发现和 SLA 事件以 PostgreSQL 为源真相;主动元数据资产是质量对象身份和根因证据的权威来源。复发次数和升级级别为确定性运营证据,不等同于自动因果结论或自动修复授权。
+- SLO、源事件消费账本、聚合告警、数据事故、影响、处置时间线和复盘以 PostgreSQL 为源真相;采集和质量原始事件仍由其原表负责。事故恢复不等于关闭,缺少责任、影响或关闭证据时必须失败关闭,事故证据不得物理删除。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 281 - 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: 290
+x-route-count: 301
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -401,6 +401,286 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/datafactory/observability/alerts":
+    get:
+      tags: [data_factory]
+      operationId: data_factory_list_observability_alerts_get
+      summary: "list observability alerts"
+      x-source: "app/api/data_factory/observability_routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/datafactory/observability/alerts/{alert_uid}/acknowledge-delivery":
+    post:
+      tags: [data_factory]
+      operationId: data_factory_acknowledge_observability_alert_delivery_post
+      summary: "acknowledge observability alert delivery"
+      x-source: "app/api/data_factory/observability_routes.py"
+      parameters:
+        - name: alert_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/datafactory/observability/alerts/{alert_uid}/suppress":
+    post:
+      tags: [data_factory]
+      operationId: data_factory_suppress_observability_alert_post
+      summary: "suppress observability alert"
+      x-source: "app/api/data_factory/observability_routes.py"
+      parameters:
+        - name: alert_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/datafactory/observability/collect":
+    post:
+      tags: [data_factory]
+      operationId: data_factory_collect_observability_signals_post
+      summary: "collect observability signals"
+      x-source: "app/api/data_factory/observability_routes.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/datafactory/observability/incidents":
+    get:
+      tags: [data_factory]
+      operationId: data_factory_list_data_incidents_get
+      summary: "list data incidents"
+      x-source: "app/api/data_factory/observability_routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/datafactory/observability/incidents/{incident_uid}":
+    get:
+      tags: [data_factory]
+      operationId: data_factory_get_data_incident_get
+      summary: "get data incident"
+      x-source: "app/api/data_factory/observability_routes.py"
+      parameters:
+        - name: incident_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/datafactory/observability/incidents/{incident_uid}/close":
+    post:
+      tags: [data_factory]
+      operationId: data_factory_close_data_incident_post
+      summary: "close data incident"
+      x-source: "app/api/data_factory/observability_routes.py"
+      parameters:
+        - name: incident_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/datafactory/observability/incidents/{incident_uid}/escalate":
+    post:
+      tags: [data_factory]
+      operationId: data_factory_escalate_data_incident_post
+      summary: "escalate data incident"
+      x-source: "app/api/data_factory/observability_routes.py"
+      parameters:
+        - name: incident_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/datafactory/observability/overview":
+    get:
+      tags: [data_factory]
+      operationId: data_factory_get_observability_overview_get
+      summary: "get observability overview"
+      x-source: "app/api/data_factory/observability_routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/datafactory/observability/slos":
+    get:
+      tags: [data_factory]
+      operationId: data_factory_list_slo_policies_get
+      summary: "list slo policies"
+      x-source: "app/api/data_factory/observability_routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [data_factory]
+      operationId: data_factory_create_slo_policy_post
+      summary: "create slo policy"
+      x-source: "app/api/data_factory/observability_routes.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/datafactory/workflows":
     get:
       tags: [data_factory]

+ 95 - 0
docs/phase2/P2_WP05_DATA_OBSERVABILITY_INCIDENTS.md

@@ -0,0 +1,95 @@
+# P2-WP05 数据可观测与事故工程说明
+
+## 1. 交付结论
+
+P2-WP05 已完成本地工程实现和定向验证。平台将主动元数据采集失败、后续恢复
+以及 P2-WP04 的质量 SLA 违约和恢复事件,转换为统一、可去重的数据告警与
+事故证据链。事故保留责任人、影响对象、处置时间线、送达回执、恢复证据和
+复盘关闭记录。
+
+这项结论只代表当前分支的工程门禁通过,不代表企业告警渠道已经接通、容量
+阈值已经配置、正式责任人已经绑定、业务验收已经完成或生产环境已经部署。
+
+## 2. 复用与权威边界
+
+- 采集信号来自 `active_metadata_runs`,不由客户端伪造采集结果;
+- 质量信号直接消费 `quality_sla_events`,不重复计算 P2-WP04 的确定性质量
+  结论;
+- 设备运行与维护事件仍由设备可观测模块保管,统一事故层只引用运营异常,
+  不复制设备台账或根因数据;
+- 相同相关键和 SLI 的异常聚合到同一告警与未关闭事故;
+- 每个源事件先以唯一键原子登记,再在同一事务内生成告警和事故,避免并发
+  采集产生重复证据;
+- 所有输入拒绝秘密字段,事故层不保存连接凭据、令牌或任意客户端根因推测。
+
+## 3. SLI / SLO
+
+平台定义四类统一指标:
+
+| SLI | 单位 | 当前权威来源 |
+| --- | --- | --- |
+| 新鲜度 | 秒 | 质量执行的源观测时间和新鲜度 SLA |
+| 完整性 | 比例 | 确定性质量画像;企业 SLO 可配置目标与窗口 |
+| 质量 | 分数 | P2-WP04 质量得分 SLA |
+| 交付 | 比例 | 主动元数据采集成功、失败和恢复事件 |
+
+SLO 保存编码、范围类型、范围标识、比较符、目标值、统计窗口和责任人。viewer
+可查看,editor 可运营告警和事故,admin 才能建立企业 SLO。
+
+## 4. 告警与事故闭环
+
+- 告警:重复次数、严重度、升级级别、抑制截止时间、抑制原因、送达状态和
+  回执;
+- 事故:编号、责任人、严重度、状态、首次发现和最近观测时间;
+- 影响:资产、数据产品、业务域、用户群和平台服务;
+- 时间线:告警建立/聚合、抑制、送达回执、升级、恢复和关闭;
+- 恢复:权威源产生恢复事件后进入“恢复观察”,不自动关闭事故;
+- 关闭:只有全部关联告警恢复,且责任、影响、根因、用户影响、纠正措施和
+  关闭证据齐全时才允许关闭;
+- 复盘:根因是运营人员基于证据确认的结论,平台不会用 AI 推测替代。
+
+主动元数据计划可在受控 `scope` 中绑定业务域、数据产品和用户群;质量事故
+从主动元数据资产、质量批次及已有数据产品映射读取影响。缺少企业绑定时只
+展示现有权威对象,不填造影响关系。
+
+## 5. 统一观测视图
+
+操作台 `/dataFactory/observability` 同时展示:
+
+- 服务:事务事件箱积压与失败证据;
+- 任务:采集/交付告警;
+- 数据:质量与新鲜度告警;
+- 容量:数据库当前用量;未配置企业容量上限时明确显示“未知”,不推测为
+  健康。
+
+操作台提供 SLO、聚合告警、事故列表及事故证据三个工作区,并支持信号采集、
+告警抑制、送达回执、事故升级和复盘关闭。
+
+## 6. 数据与接口
+
+数据库迁移 `20260731_410` 新增:
+
+- `data_slo_policies`;
+- `data_observability_source_events`;
+- `data_observability_alerts`;
+- `data_incidents`、`data_incident_impacts`;
+- `data_incident_timeline`、`data_incident_postmortems`。
+
+接口位于 `/api/datafactory/observability`,覆盖统一视图、SLO、源事件采集、
+告警操作、事故列表/详情、升级与关闭。
+
+## 7. 定向验证
+
+本工作包只执行修改范围内验证:
+
+- SLI/SLO、告警聚合、抑制、回执、升级、恢复和关闭门禁核心测试;
+- viewer/editor/admin API 权限和响应契约;
+- 前端入口与运营能力契约;
+- 全新 PostgreSQL 数据库从空库迁移至 `20260731_410`;
+- PostgreSQL 采集失败、质量 SLA 违约、两类恢复、影响映射和复盘关闭集成
+  测试;
+- OpenAPI 可重现性、前端定向检查和生产构建;
+- `app/`、`migrations/` 与 `deployment/` 发布副本一致性。
+
+企业验收仍需在 P2-WP12、P2-WP13 提供正式告警渠道、送达回执协议、容量
+配额、责任人、数据产品/业务域/用户群绑定、SLO 阈值和统计窗口。

+ 27 - 0
frontend/src/api/dataObservability.js

@@ -0,0 +1,27 @@
+import http from '@/utils/request'
+
+const BASE = '/datafactory/observability'
+
+export const getObservabilityOverview = () => http.get(`${BASE}/overview`)
+export const getSloPolicies = () => http.get(`${BASE}/slos`)
+export const createSloPolicy = params => http.post(`${BASE}/slos`, params)
+export const collectObservabilitySignals = () => http.post(`${BASE}/collect`, {})
+export const getObservabilityAlerts = () => http.get(`${BASE}/alerts`)
+export const suppressObservabilityAlert = (uid, params) => http.post(
+  `${BASE}/alerts/${uid}/suppress`,
+  params
+)
+export const acknowledgeAlertDelivery = (uid, params) => http.post(
+  `${BASE}/alerts/${uid}/acknowledge-delivery`,
+  params
+)
+export const getDataIncidents = () => http.get(`${BASE}/incidents`)
+export const getDataIncident = uid => http.get(`${BASE}/incidents/${uid}`)
+export const escalateDataIncident = (uid, reason) => http.post(
+  `${BASE}/incidents/${uid}/escalate`,
+  { reason }
+)
+export const closeDataIncident = (uid, params) => http.post(
+  `${BASE}/incidents/${uid}/close`,
+  params
+)

+ 45 - 1
frontend/src/router/routes.js

@@ -1513,6 +1513,50 @@ export default {
           metastr: '{"keepAlive":false,"allowClick":false,"enName":"Workflow","editModules":false,"title":"工作流管理","fullScreen":false,"target":false}',
           open: null
         },
+        {
+          meun: '',
+          code: '',
+          hidden: 0,
+          rootId: 972,
+          icon: '',
+          remark: '',
+          type: 1,
+          title: '数据可观测与事故',
+          local: '',
+          path: '/dataFactory/observability',
+          urls: '',
+          children: [],
+          enName: 'Data Observability',
+          id: 977,
+          redirect: '',
+          level: 2,
+          openPath: '',
+          active: '',
+          label: '数据可观测与事故',
+          sort: 8,
+          parentId: 972,
+          effectiveStatus: true,
+          parentName: 'dataFactory',
+          component: 'dataFactory/observability',
+          meta: {
+            keepAlive: false,
+            allowClick: false,
+            roles: ['viewer', 'editor', 'admin'],
+            permissions: ['data-observability:read'],
+            enName: 'Data Observability',
+            icon: '',
+            editModules: false,
+            title: '数据可观测与事故',
+            fullScreen: false,
+            target: false,
+            effectiveStatus: true
+          },
+          name: 'dataObservability',
+          style: '',
+          alwaysShow: 0,
+          metastr: '{"keepAlive":false,"allowClick":false,"permissions":["data-observability:read"],"enName":"Data Observability","editModules":false,"title":"数据可观测与事故","fullScreen":false,"target":false}',
+          open: null
+        },
         {
           meun: '',
           code: '',
@@ -1533,7 +1577,7 @@ export default {
           openPath: '',
           active: '',
           label: '数据库连接池管理',
-          sort: 8,
+          sort: 9,
           parentId: 972,
           effectiveStatus: true,
           parentName: 'dataFactory',

+ 476 - 0
frontend/src/views/dataFactory/observability/index.vue

@@ -0,0 +1,476 @@
+<template>
+  <div class="pa-6 data-observability">
+    <div class="d-flex flex-wrap align-start justify-space-between mb-5">
+      <div>
+        <h1 class="text-h4 mb-2">数据可观测与事故</h1>
+        <p class="text--secondary mb-0">
+          汇总服务、任务、数据和容量信号,把确定性异常组织成可追责、可恢复、可复盘的事故闭环。
+        </p>
+      </div>
+      <div class="mt-2">
+        <v-btn
+          v-if="canManage"
+          outlined
+          color="primary"
+          class="mr-2"
+          @click="sloDialog = true"
+        >新建 SLO</v-btn>
+        <v-btn
+          v-if="canOperate"
+          color="primary"
+          :loading="collecting"
+          @click="collectSignals"
+        >采集运行信号</v-btn>
+      </div>
+    </div>
+
+    <v-alert type="info" outlined class="mb-5">
+      新鲜度、完整性、质量和交付 SLI/SLO 使用已保存的采集与质量证据。
+      重复告警会聚合;抑制、升级、送达回执、恢复事件和关闭证据均进入处置时间线。
+    </v-alert>
+
+    <v-row>
+      <v-col v-for="layer in layerOrder" :key="layer" cols="12" sm="6" lg="3">
+        <v-card outlined class="pa-4 fill-height">
+          <div class="text-overline">{{ layerLabel(layer) }}</div>
+          <div class="d-flex mt-2">
+            <v-chip small color="success" outlined class="mr-2">
+              正常 {{ layerMetric(layer, 'healthy') }}
+            </v-chip>
+            <v-chip small color="warning" outlined class="mr-2">
+              关注 {{ layerMetric(layer, 'warning') }}
+            </v-chip>
+            <v-chip small color="error" outlined>
+              严重 {{ layerMetric(layer, 'critical') }}
+            </v-chip>
+          </div>
+          <div
+            v-if="layer === 'capacity' && layerMetric(layer, 'unknown')"
+            class="caption text--secondary mt-3"
+          >
+            容量上限尚未绑定,当前明确显示为未知,不推测健康状态。
+          </div>
+        </v-card>
+      </v-col>
+    </v-row>
+
+    <v-card outlined class="mb-5">
+      <v-card-title>
+        SLI / SLO
+        <v-spacer />
+        <v-chip
+          v-for="item in sliCatalog"
+          :key="item.type"
+          small
+          outlined
+          class="ml-2"
+        >{{ sliLabel(item.type) }}</v-chip>
+      </v-card-title>
+      <v-data-table
+        :headers="sloHeaders"
+        :items="slos"
+        :loading="loading"
+        dense
+      >
+        <template v-slot:[`item.sli_type`]="{ item }">
+          {{ sliLabel(item.sli_type) }}
+        </template>
+        <template v-slot:[`item.target`]="{ item }">
+          {{ item.operator }} {{ item.target }}
+        </template>
+        <template #no-data>
+          <div class="py-6 text--secondary">
+            暂无企业 SLO。平台仍会展示质量与采集的原始 SLA 事件。
+          </div>
+        </template>
+      </v-data-table>
+    </v-card>
+
+    <v-tabs v-model="tab" background-color="transparent" class="mb-4">
+      <v-tab>告警</v-tab>
+      <v-tab>事故</v-tab>
+      <v-tab :disabled="!selectedIncident">事故证据</v-tab>
+    </v-tabs>
+
+    <v-card v-if="tab === 0" outlined>
+      <v-card-title>聚合告警</v-card-title>
+      <v-data-table :headers="alertHeaders" :items="alerts" :loading="loading">
+        <template v-slot:[`item.status`]="{ item }">
+          <v-chip small outlined :color="statusView(item.status).color">
+            {{ statusView(item.status).label }}
+          </v-chip>
+        </template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <v-btn
+            v-if="canOperate && item.status !== 'recovered'"
+            text
+            small
+            @click="openSuppress(item)"
+          >抑制</v-btn>
+          <v-btn
+            v-if="canOperate"
+            text
+            small
+            color="primary"
+            @click="openReceipt(item)"
+          >送达回执</v-btn>
+        </template>
+      </v-data-table>
+    </v-card>
+
+    <v-card v-else-if="tab === 1" outlined>
+      <v-card-title>数据事故</v-card-title>
+      <v-data-table :headers="incidentHeaders" :items="incidents" :loading="loading">
+        <template v-slot:[`item.status`]="{ item }">
+          <v-chip small outlined :color="statusView(item.status).color">
+            {{ statusView(item.status).label }}
+          </v-chip>
+        </template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <v-btn text small color="primary" @click="openIncident(item)">查看证据</v-btn>
+          <v-btn
+            v-if="canOperate && item.status !== 'closed'"
+            text
+            small
+            @click="escalate(item)"
+          >升级</v-btn>
+        </template>
+      </v-data-table>
+    </v-card>
+
+    <v-card v-else outlined>
+      <v-card-title>
+        {{ selectedIncident && selectedIncident.code }} 事故证据
+        <v-spacer />
+        <v-btn
+          v-if="canOperate && selectedIncident && selectedIncident.status === 'monitoring'"
+          color="success"
+          outlined
+          @click="closeDialog = true"
+        >提交复盘并关闭</v-btn>
+      </v-card-title>
+      <v-card-text v-if="selectedIncident">
+        <v-row>
+          <v-col cols="12" md="4">
+            <strong>责任人</strong><br>{{ selectedIncident.owner_uid }}
+          </v-col>
+          <v-col cols="12" md="4">
+            <strong>级别</strong><br>L{{ selectedIncident.escalation_level }}
+          </v-col>
+          <v-col cols="12" md="4">
+            <strong>状态</strong><br>{{ statusView(selectedIncident.status).label }}
+          </v-col>
+        </v-row>
+        <v-divider class="my-4" />
+        <div class="text-subtitle-1 mb-2">影响资产、数据产品、业务域和用户影响</div>
+        <v-chip
+          v-for="impact in selectedIncident.impacts || []"
+          :key="`${impact.target_type}:${impact.target_uid}`"
+          outlined
+          class="mr-2 mb-2"
+        >
+          {{ impact.target_type }} · {{ impact.label }}
+        </v-chip>
+        <div class="text-subtitle-1 mt-4 mb-2">处置时间线</div>
+        <v-timeline dense>
+          <v-timeline-item
+            v-for="item in selectedIncident.timeline || []"
+            :key="item.uid"
+            small
+          >
+            <strong>{{ item.action }}</strong>
+            <div class="caption">{{ item.created_at }}</div>
+          </v-timeline-item>
+        </v-timeline>
+        <v-alert
+          v-if="selectedIncident.postmortem"
+          type="success"
+          outlined
+          class="mt-4"
+        >
+          事故复盘:{{ selectedIncident.postmortem.root_cause }};
+          关闭证据 {{ selectedIncident.postmortem.closure_evidence.length }} 项。
+        </v-alert>
+      </v-card-text>
+    </v-card>
+
+    <v-dialog v-model="sloDialog" max-width="680">
+      <v-card>
+        <v-card-title>新建 SLO</v-card-title>
+        <v-card-text>
+          <v-textarea
+            v-model="sloText"
+            rows="13"
+            label="SLO 定义 JSON"
+            hint="设置范围、目标、统计窗口和责任人"
+            persistent-hint
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="sloDialog = false">取消</v-btn>
+          <v-btn color="primary" @click="saveSlo">保存</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="actionDialog" max-width="620">
+      <v-card>
+        <v-card-title>{{ actionMode === 'suppress' ? '抑制告警' : '登记送达回执' }}</v-card-title>
+        <v-card-text>
+          <v-textarea v-model="actionText" rows="7" label="操作参数 JSON" />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="actionDialog = false">取消</v-btn>
+          <v-btn color="primary" @click="saveAlertAction">确认</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="closeDialog" max-width="720">
+      <v-card>
+        <v-card-title>事故复盘与关闭证据</v-card-title>
+        <v-card-text>
+          <v-textarea v-model="closeText" rows="14" label="复盘 JSON" />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="closeDialog = false">取消</v-btn>
+          <v-btn color="success" @click="closeIncident">验证并关闭</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  getObservabilityOverview,
+  getSloPolicies,
+  createSloPolicy,
+  collectObservabilitySignals,
+  getObservabilityAlerts,
+  suppressObservabilityAlert,
+  acknowledgeAlertDelivery,
+  getDataIncidents,
+  getDataIncident,
+  escalateDataIncident,
+  closeDataIncident
+} from '@/api/dataObservability'
+import {
+  canOperateObservability,
+  canManageObservability,
+  incidentStatusView,
+  layerNames,
+  sliNames
+} from './model'
+
+export default {
+  name: 'DataObservability',
+  data () {
+    return {
+      loading: false,
+      collecting: false,
+      tab: 0,
+      overview: { layers: {} },
+      slos: [],
+      alerts: [],
+      incidents: [],
+      selectedIncident: null,
+      sloDialog: false,
+      actionDialog: false,
+      closeDialog: false,
+      actionMode: 'suppress',
+      actionAlert: null,
+      layerOrder: ['service', 'task', 'data', 'capacity'],
+      sloText: JSON.stringify({
+        code: 'DEVICE_DELIVERY_SLO',
+        name: '设备台账按时交付',
+        sli_type: 'delivery',
+        scope_type: 'data_product',
+        scope_uid: 'device-ledger',
+        operator: '>=',
+        target: 0.99,
+        window_seconds: 86400,
+        owner_uid: ''
+      }, null, 2),
+      actionText: '{}',
+      closeText: JSON.stringify({
+        root_cause: '',
+        user_impact: '',
+        corrective_actions: [],
+        closure_evidence: []
+      }, null, 2),
+      sloHeaders: [
+        { text: '编码', value: 'code' },
+        { text: 'SLI', value: 'sli_type' },
+        { text: '范围', value: 'scope_uid' },
+        { text: '目标', value: 'target' },
+        { text: '责任人', value: 'owner_uid' }
+      ],
+      alertHeaders: [
+        { text: '告警', value: 'title' },
+        { text: '层级', value: 'layer' },
+        { text: '状态', value: 'status' },
+        { text: '重复次数', value: 'occurrence_count' },
+        { text: '升级级别', value: 'escalation_level' },
+        { text: '送达状态', value: 'delivery_status' },
+        { text: '操作', value: 'actions', sortable: false }
+      ],
+      incidentHeaders: [
+        { text: '编号', value: 'code' },
+        { text: '事故', value: 'title' },
+        { text: '状态', value: 'status' },
+        { text: '严重度', value: 'severity' },
+        { text: '责任人', value: 'owner_uid' },
+        { text: '操作', value: 'actions', sortable: false }
+      ]
+    }
+  },
+  computed: {
+    permissions () {
+      return this.$store.state.user.userInfo.permissions || []
+    },
+    canOperate () {
+      return canOperateObservability(this.permissions)
+    },
+    canManage () {
+      return canManageObservability(this.permissions)
+    },
+    sliCatalog () {
+      return this.overview.sli_catalog || []
+    }
+  },
+  created () {
+    this.loadAll()
+  },
+  methods: {
+    async loadAll () {
+      this.loading = true
+      try {
+        const [overview, slos, alerts, incidents] = await Promise.all([
+          getObservabilityOverview(),
+          getSloPolicies(),
+          getObservabilityAlerts(),
+          getDataIncidents()
+        ])
+        this.overview = overview.data || { layers: {} }
+        this.slos = slos.data || []
+        this.alerts = alerts.data || []
+        this.incidents = incidents.data || []
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loading = false
+      }
+    },
+    async collectSignals () {
+      this.collecting = true
+      try {
+        const response = await collectObservabilitySignals()
+        await this.loadAll()
+        this.$snackbar.success(`已处理 ${response.data.processed} 个确定性运行信号`)
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.collecting = false
+      }
+    },
+    async saveSlo () {
+      try {
+        await createSloPolicy(JSON.parse(this.sloText))
+        this.sloDialog = false
+        await this.loadAll()
+        this.$snackbar.success('SLO 已保存')
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    openSuppress (item) {
+      this.actionMode = 'suppress'
+      this.actionAlert = item
+      this.actionText = JSON.stringify({
+        until: new Date(Date.now() + 3600000).toISOString(),
+        reason: ''
+      }, null, 2)
+      this.actionDialog = true
+    },
+    openReceipt (item) {
+      this.actionMode = 'receipt'
+      this.actionAlert = item
+      this.actionText = JSON.stringify({
+        channel: 'operations-console',
+        receipt_id: ''
+      }, null, 2)
+      this.actionDialog = true
+    },
+    async saveAlertAction () {
+      try {
+        const payload = JSON.parse(this.actionText)
+        if (this.actionMode === 'suppress') {
+          await suppressObservabilityAlert(this.actionAlert.uid, payload)
+        } else {
+          await acknowledgeAlertDelivery(this.actionAlert.uid, payload)
+        }
+        this.actionDialog = false
+        await this.loadAll()
+        this.$snackbar.success('告警操作已写入处置时间线')
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    async openIncident (item) {
+      try {
+        const response = await getDataIncident(item.uid)
+        this.selectedIncident = response.data
+        this.tab = 2
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    async escalate (item) {
+      try {
+        await escalateDataIncident(item.uid, '运营人员从统一观测台升级')
+        await this.loadAll()
+        this.$snackbar.success('事故已升级')
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    async closeIncident () {
+      try {
+        const response = await closeDataIncident(
+          this.selectedIncident.uid,
+          JSON.parse(this.closeText)
+        )
+        this.selectedIncident = response.data
+        this.closeDialog = false
+        await this.loadAll()
+        this.$snackbar.success('复盘和关闭证据已保存,事故已关闭')
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    layerLabel (value) {
+      return layerNames[value] || value
+    },
+    sliLabel (value) {
+      return sliNames[value] || value
+    },
+    layerMetric (layer, metric) {
+      return (this.overview.layers[layer] || {})[metric] || 0
+    },
+    statusView (status) {
+      return incidentStatusView(status)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.data-observability {
+  max-width: 1600px;
+  margin: 0 auto;
+}
+</style>

+ 32 - 0
frontend/src/views/dataFactory/observability/model.js

@@ -0,0 +1,32 @@
+export function canOperateObservability (permissions) {
+  return (permissions || []).includes('data-observability:operate')
+}
+
+export function canManageObservability (permissions) {
+  return (permissions || []).includes('data-observability:manage')
+}
+
+export function incidentStatusView (status) {
+  const views = {
+    open: { label: '处理中', color: 'error' },
+    monitoring: { label: '恢复观察', color: 'warning' },
+    closed: { label: '已关闭', color: 'success' },
+    suppressed: { label: '已抑制', color: 'grey' },
+    recovered: { label: '已恢复', color: 'success' }
+  }
+  return views[status] || { label: status || '—', color: 'grey' }
+}
+
+export const layerNames = {
+  service: '服务',
+  task: '任务',
+  data: '数据',
+  capacity: '容量'
+}
+
+export const sliNames = {
+  freshness: '新鲜度',
+  completeness: '完整性',
+  quality: '质量',
+  delivery: '交付'
+}

+ 183 - 0
migrations/versions/20260731_410_data_observability.py

@@ -0,0 +1,183 @@
+"""Add canonical SLO policies, alerts and evidence-bound data incidents."""
+
+from alembic import op
+
+revision = "20260731_410"
+down_revision = "20260731_400"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.data_slo_policies (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            sli_type VARCHAR(30) NOT NULL CHECK (
+                sli_type IN ('freshness','completeness','quality','delivery')
+            ),
+            scope_type VARCHAR(40) NOT NULL,
+            scope_uid VARCHAR(500) NOT NULL,
+            operator VARCHAR(2) NOT NULL CHECK (operator IN ('>=','<=')),
+            target NUMERIC NOT NULL,
+            window_seconds INTEGER NOT NULL CHECK (
+                window_seconds BETWEEN 60 AND 31536000
+            ),
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('active','retired')
+            ),
+            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,
+            UNIQUE (sli_type, scope_type, scope_uid)
+        );
+
+        CREATE TABLE public.data_incidents (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(40) NOT NULL UNIQUE,
+            dedup_key CHAR(64) NOT NULL,
+            title VARCHAR(300) NOT NULL,
+            severity VARCHAR(20) NOT NULL CHECK (
+                severity IN ('info','warning','error','critical')
+            ),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('open','monitoring','closed')
+            ),
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            escalation_level INTEGER NOT NULL CHECK (
+                escalation_level BETWEEN 1 AND 3
+            ),
+            first_detected_at TIMESTAMPTZ NOT NULL,
+            last_observed_at TIMESTAMPTZ NOT NULL,
+            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,
+            closed_by UUID REFERENCES public.users(id),
+            closed_at TIMESTAMPTZ
+        );
+
+        CREATE UNIQUE INDEX uq_data_incident_open_dedup
+            ON public.data_incidents(dedup_key)
+            WHERE status <> 'closed';
+
+        CREATE TABLE public.data_observability_alerts (
+            uid UUID PRIMARY KEY,
+            dedup_key CHAR(64) NOT NULL,
+            incident_uid UUID NOT NULL REFERENCES public.data_incidents(uid),
+            layer VARCHAR(20) NOT NULL CHECK (
+                layer IN ('service','task','data','capacity')
+            ),
+            sli_type VARCHAR(30) NOT NULL CHECK (
+                sli_type IN ('freshness','completeness','quality','delivery')
+            ),
+            title VARCHAR(300) NOT NULL,
+            severity VARCHAR(20) NOT NULL CHECK (
+                severity IN ('info','warning','error','critical')
+            ),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('open','suppressed','recovered')
+            ),
+            occurrence_count INTEGER NOT NULL CHECK (occurrence_count > 0),
+            escalation_level INTEGER NOT NULL CHECK (
+                escalation_level BETWEEN 1 AND 3
+            ),
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            first_observed_at TIMESTAMPTZ NOT NULL,
+            last_observed_at TIMESTAMPTZ NOT NULL,
+            suppressed_until TIMESTAMPTZ,
+            suppression_reason VARCHAR(500),
+            delivery_status VARCHAR(30) NOT NULL CHECK (
+                delivery_status IN (
+                    'pending','suppressed','acknowledged','recovery_pending'
+                )
+            ),
+            delivery_receipt JSONB NOT NULL DEFAULT '{}'::jsonb,
+            evidence JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(delivery_receipt) = 'object'),
+            CHECK (jsonb_typeof(evidence) = 'object')
+        );
+
+        CREATE UNIQUE INDEX uq_data_alert_open_dedup
+            ON public.data_observability_alerts(dedup_key)
+            WHERE status <> 'recovered';
+
+        CREATE TABLE public.data_observability_source_events (
+            uid UUID PRIMARY KEY,
+            source_event_key VARCHAR(500) NOT NULL UNIQUE,
+            source_type VARCHAR(80) NOT NULL,
+            source_uid VARCHAR(500) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('violated','recovered')
+            ),
+            signal JSONB NOT NULL,
+            processed_at TIMESTAMPTZ NOT NULL,
+            CHECK (jsonb_typeof(signal) = 'object')
+        );
+
+        CREATE TABLE public.data_incident_impacts (
+            uid BIGSERIAL PRIMARY KEY,
+            incident_uid UUID NOT NULL
+                REFERENCES public.data_incidents(uid) ON DELETE CASCADE,
+            target_type VARCHAR(40) NOT NULL CHECK (
+                target_type IN (
+                    'asset','data_product','business_domain',
+                    'user_group','service'
+                )
+            ),
+            target_uid VARCHAR(500) NOT NULL,
+            label VARCHAR(300) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (incident_uid, target_type, target_uid)
+        );
+
+        CREATE TABLE public.data_incident_timeline (
+            uid UUID PRIMARY KEY,
+            incident_uid UUID NOT NULL
+                REFERENCES public.data_incidents(uid) ON DELETE CASCADE,
+            action VARCHAR(40) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            evidence JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(evidence) = 'object')
+        );
+
+        CREATE TABLE public.data_incident_postmortems (
+            uid UUID PRIMARY KEY,
+            incident_uid UUID NOT NULL UNIQUE
+                REFERENCES public.data_incidents(uid) ON DELETE CASCADE,
+            root_cause TEXT NOT NULL,
+            user_impact TEXT NOT NULL,
+            corrective_actions JSONB NOT NULL,
+            closure_evidence JSONB NOT NULL,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(corrective_actions) = 'array'),
+            CHECK (jsonb_array_length(corrective_actions) > 0),
+            CHECK (jsonb_typeof(closure_evidence) = 'array'),
+            CHECK (jsonb_array_length(closure_evidence) > 0)
+        );
+
+        CREATE INDEX idx_data_alert_layer_status
+            ON public.data_observability_alerts(
+                layer, status, severity, last_observed_at DESC
+            );
+        CREATE INDEX idx_data_incident_status_updated
+            ON public.data_incidents(status, updated_at DESC);
+        CREATE INDEX idx_data_incident_timeline_created
+            ON public.data_incident_timeline(
+                incident_uid, created_at, uid
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "data incident evidence is append-only; "
+        "downgrade requires an approved archival migration"
+    )

+ 378 - 0
tests/core/orchestration/test_data_observability.py

@@ -0,0 +1,378 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+from app.core.events.data_observability import DataObservabilityService
+
+ACTOR_UID = "01900000-0000-7000-8000-000000005001"
+OWNER_UID = "01900000-0000-7000-8000-000000005002"
+ASSET_UID = "01900000-0000-7000-8000-000000005003"
+PLAN_UID = "01900000-0000-7000-8000-000000005004"
+
+
+class MemoryObservabilityRepository:
+    def __init__(self):
+        self.policies = []
+        self.pending = []
+        self.source_events = {}
+        self.alerts = {}
+        self.incidents = {}
+        self.impacts = []
+        self.timeline = []
+        self.postmortems = []
+
+    def create_slo(self, record):
+        self.policies.append(deepcopy(record))
+        return deepcopy(record)
+
+    def list_slos(self):
+        return deepcopy(self.policies)
+
+    def pending_signals(self):
+        return deepcopy(self.pending)
+
+    def source_event_exists(self, source_event_key):
+        return source_event_key in self.source_events
+
+    def record_source_event(self, record):
+        self.source_events[record["source_event_key"]] = deepcopy(record)
+
+    def find_alert(self, dedup_key):
+        return next(
+            (
+                deepcopy(item)
+                for item in self.alerts.values()
+                if item["dedup_key"] == dedup_key
+            ),
+            None,
+        )
+
+    def create_alert(self, record):
+        self.alerts[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def update_alert(self, record):
+        self.alerts[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def find_open_incident(self, dedup_key):
+        return next(
+            (
+                deepcopy(item)
+                for item in self.incidents.values()
+                if item["dedup_key"] == dedup_key
+                and item["status"] != "closed"
+            ),
+            None,
+        )
+
+    def create_incident(self, record):
+        self.incidents[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def update_incident(self, record):
+        self.incidents[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def save_impacts(self, incident_uid, impacts):
+        for item in impacts:
+            value = deepcopy(item)
+            value["incident_uid"] = incident_uid
+            if value not in self.impacts:
+                self.impacts.append(value)
+
+    def append_timeline(self, record):
+        self.timeline.append(deepcopy(record))
+
+    def list_alerts(self):
+        return deepcopy(list(self.alerts.values()))
+
+    def list_incidents(self):
+        return deepcopy(list(self.incidents.values()))
+
+    def get_alert(self, uid):
+        value = self.alerts.get(uid)
+        return deepcopy(value) if value else None
+
+    def get_incident(self, uid):
+        value = self.incidents.get(uid)
+        return deepcopy(value) if value else None
+
+    def incident_detail(self, uid):
+        incident = self.get_incident(uid)
+        if not incident:
+            return None
+        return {
+            **incident,
+            "alerts": [
+                deepcopy(item)
+                for item in self.alerts.values()
+                if item.get("incident_uid") == uid
+            ],
+            "impacts": [
+                deepcopy(item)
+                for item in self.impacts
+                if item["incident_uid"] == uid
+            ],
+            "timeline": [
+                deepcopy(item)
+                for item in self.timeline
+                if item["incident_uid"] == uid
+            ],
+            "postmortem": next(
+                (
+                    deepcopy(item)
+                    for item in self.postmortems
+                    if item["incident_uid"] == uid
+                ),
+                None,
+            ),
+        }
+
+    def save_postmortem(self, record):
+        self.postmortems.append(deepcopy(record))
+
+    def overview_counts(self):
+        return {
+            "service": {"healthy": 2, "warning": 0, "critical": 0},
+            "task": {"healthy": 4, "warning": 0, "critical": 1},
+            "data": {"healthy": 7, "warning": 1, "critical": 1},
+            "capacity": {"healthy": 3, "warning": 0, "critical": 0},
+        }
+
+
+@pytest.fixture()
+def service():
+    repository = MemoryObservabilityRepository()
+    uids = (
+        f"01900000-0000-7000-8000-{value:012d}"
+        for value in range(5100, 5400)
+    )
+    clock = {"value": datetime(2026, 7, 31, 9, 0, tzinfo=UTC)}
+    instance = DataObservabilityService(
+        repository,
+        uid_factory=lambda: next(uids),
+        now_factory=lambda: clock["value"],
+    )
+    return instance, repository, clock
+
+
+def ingestion_signal(status, sequence):
+    return {
+        "source_event_key": f"active-metadata:{PLAN_UID}:{sequence}:{status}",
+        "source_type": "active_metadata_run",
+        "source_uid": f"run-{sequence}",
+        "correlation_key": f"active-metadata-plan:{PLAN_UID}",
+        "layer": "task",
+        "sli_type": "delivery",
+        "status": status,
+        "severity": "critical" if status == "violated" else "info",
+        "title": "设备主数据采集失败",
+        "owner_uid": OWNER_UID,
+        "observed_at": f"2026-07-31T09:{sequence:02d}:00+00:00",
+        "actual": 0 if status == "violated" else 1,
+        "target": 1,
+        "evidence": {
+            "plan_uid": PLAN_UID,
+            "failure_code": "SOURCE_TIMEOUT" if status == "violated" else None,
+            "deterministic": True,
+        },
+        "impacts": [
+            {"target_type": "asset", "target_uid": ASSET_UID, "label": "device"},
+            {
+                "target_type": "business_domain",
+                "target_uid": "device",
+                "label": "设备域",
+            },
+            {
+                "target_type": "data_product",
+                "target_uid": "device-ledger",
+                "label": "设备台账",
+            },
+            {
+                "target_type": "user_group",
+                "target_uid": "maintenance-ops",
+                "label": "设备运维人员",
+            },
+        ],
+    }
+
+
+def quality_signal(status, sequence):
+    return {
+        "source_event_key": f"quality-sla:{ASSET_UID}:{sequence}:{status}",
+        "source_type": "quality_sla_event",
+        "source_uid": f"quality-{sequence}",
+        "correlation_key": f"quality-sla:{ASSET_UID}:quality_score",
+        "layer": "data",
+        "sli_type": "quality",
+        "status": status,
+        "severity": "critical" if status == "violated" else "info",
+        "title": "设备台账质量 SLA 违约",
+        "owner_uid": OWNER_UID,
+        "observed_at": f"2026-07-31T10:{sequence:02d}:00+00:00",
+        "actual": 68 if status == "violated" else 92,
+        "target": 80,
+        "evidence": {
+            "run_uid": f"quality-run-{sequence}",
+            "deterministic": True,
+        },
+        "impacts": [
+            {"target_type": "asset", "target_uid": ASSET_UID, "label": "device"},
+            {
+                "target_type": "business_domain",
+                "target_uid": "device",
+                "label": "设备域",
+            },
+        ],
+    }
+
+
+def test_sli_catalog_and_slo_policy_cover_four_operational_dimensions(service):
+    observability, repository, _clock = service
+
+    assert {item["type"] for item in observability.sli_catalog()} == {
+        "freshness",
+        "completeness",
+        "quality",
+        "delivery",
+    }
+    policy = observability.create_slo(
+        {
+            "code": "DEVICE_DELIVERY_SLO",
+            "name": "设备台账按时交付",
+            "sli_type": "delivery",
+            "scope_type": "data_product",
+            "scope_uid": "device-ledger",
+            "operator": ">=",
+            "target": 0.99,
+            "window_seconds": 86400,
+            "owner_uid": OWNER_UID,
+        },
+        actor_uid=ACTOR_UID,
+    )
+
+    assert policy["status"] == "active"
+    assert policy["target"] == 0.99
+    assert repository.policies[0]["created_by"] == ACTOR_UID
+
+
+def test_ingestion_failure_deduplicates_alert_and_builds_incident_evidence(service):
+    observability, repository, _clock = service
+    repository.pending.extend(
+        [ingestion_signal("violated", 1), ingestion_signal("violated", 2)]
+    )
+
+    result = observability.collect(actor_uid=ACTOR_UID)
+    incident = observability.incident_detail(result["incident_uids"][0])
+
+    assert result == {
+        "processed": 2,
+        "ignored": 0,
+        "created_alerts": 1,
+        "aggregated_alerts": 1,
+        "recovered_alerts": 0,
+        "incident_uids": [incident["uid"]],
+    }
+    assert len(incident["alerts"]) == 1
+    assert incident["alerts"][0]["occurrence_count"] == 2
+    assert incident["alerts"][0]["escalation_level"] == 2
+    assert incident["owner_uid"] == OWNER_UID
+    assert {item["target_type"] for item in incident["impacts"]} == {
+        "asset",
+        "business_domain",
+        "data_product",
+        "user_group",
+    }
+    assert any(
+        item["action"] == "alert_aggregated" for item in incident["timeline"]
+    )
+    assert all(
+        item["evidence"]["deterministic"] for item in incident["timeline"]
+    )
+
+
+def test_quality_sla_alert_supports_suppression_delivery_ack_and_escalation(service):
+    observability, repository, clock = service
+    repository.pending.append(quality_signal("violated", 1))
+    collected = observability.collect(actor_uid=ACTOR_UID)
+    incident_uid = collected["incident_uids"][0]
+    alert_uid = observability.incident_detail(incident_uid)["alerts"][0]["uid"]
+
+    suppressed = observability.suppress_alert(
+        alert_uid,
+        until=clock["value"] + timedelta(hours=2),
+        reason="已进入计划维护窗口",
+        actor_uid=ACTOR_UID,
+    )
+    acknowledged = observability.acknowledge_delivery(
+        alert_uid,
+        channel="operations-console",
+        receipt_id="receipt-001",
+        actor_uid=ACTOR_UID,
+    )
+    escalated = observability.escalate_incident(
+        incident_uid,
+        reason="已影响设备台账交付",
+        actor_uid=ACTOR_UID,
+    )
+
+    assert suppressed["status"] == "suppressed"
+    assert acknowledged["delivery_status"] == "acknowledged"
+    assert acknowledged["delivery_receipt"]["receipt_id"] == "receipt-001"
+    assert escalated["escalation_level"] == 2
+
+
+def test_recovery_requires_postmortem_and_closure_evidence(service):
+    observability, repository, _clock = service
+    repository.pending.append(ingestion_signal("violated", 1))
+    first = observability.collect(actor_uid=ACTOR_UID)
+    incident_uid = first["incident_uids"][0]
+    repository.pending = [ingestion_signal("recovered", 3)]
+
+    recovered = observability.collect(actor_uid=ACTOR_UID)
+    incident = observability.incident_detail(incident_uid)
+
+    assert recovered["recovered_alerts"] == 1
+    assert incident["status"] == "monitoring"
+    assert incident["alerts"][0]["status"] == "recovered"
+    assert any(item["action"] == "recovered" for item in incident["timeline"])
+
+    with pytest.raises(ValueError, match="postmortem"):
+        observability.close_incident(
+            incident_uid,
+            {"root_cause": "source timeout"},
+            actor_uid=ACTOR_UID,
+        )
+
+    closed = observability.close_incident(
+        incident_uid,
+        {
+            "root_cause": "源系统网络超时导致采集窗口失败",
+            "user_impact": "设备运维人员在一个交付窗口内看到旧台账",
+            "corrective_actions": ["增加源端超时监测", "调整重试退避参数"],
+            "closure_evidence": [
+                {"kind": "recovery_run", "ref": "run-3"},
+                {"kind": "verification", "ref": "delivery-check-3"},
+            ],
+        },
+        actor_uid=ACTOR_UID,
+    )
+
+    assert closed["status"] == "closed"
+    assert closed["postmortem"]["root_cause"].startswith("源系统")
+    assert closed["closed_by"] == ACTOR_UID
+    assert any(item["action"] == "closed" for item in closed["timeline"])
+
+
+def test_overview_keeps_service_task_data_and_capacity_layers_visible(service):
+    observability, _repository, _clock = service
+
+    overview = observability.overview()
+
+    assert list(overview["layers"]) == ["service", "task", "data", "capacity"]
+    assert overview["layers"]["task"]["critical"] == 1
+    assert len(overview["sli_catalog"]) == 4

+ 190 - 0
tests/core/orchestration/test_data_observability_api.py

@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+import pytest
+
+ALERT_UID = "01900000-0000-7000-8000-000000005501"
+INCIDENT_UID = "01900000-0000-7000-8000-000000005502"
+
+
+class FakeDataObservabilityService:
+    def __init__(self):
+        self.actions = []
+        self.alert = {
+            "uid": ALERT_UID,
+            "incident_uid": INCIDENT_UID,
+            "status": "open",
+            "delivery_status": "pending",
+        }
+        self.incident = {
+            "uid": INCIDENT_UID,
+            "status": "open",
+            "alerts": [self.alert],
+            "impacts": [{"target_type": "asset", "target_uid": "asset-1"}],
+            "timeline": [],
+        }
+
+    def overview(self):
+        self.actions.append(("overview",))
+        return {"layers": {"service": {}, "task": {}, "data": {}, "capacity": {}}}
+
+    def list_slos(self):
+        self.actions.append(("list_slos",))
+        return [{"uid": "slo-1", "sli_type": "delivery"}]
+
+    def create_slo(self, payload, *, actor_uid):
+        self.actions.append(("create_slo", payload, actor_uid))
+        return {"uid": "slo-1", **payload}
+
+    def collect(self, *, actor_uid):
+        self.actions.append(("collect", actor_uid))
+        return {"processed": 2, "incident_uids": [INCIDENT_UID]}
+
+    def list_alerts(self):
+        self.actions.append(("list_alerts",))
+        return [self.alert]
+
+    def suppress_alert(self, uid, *, until, reason, actor_uid):
+        self.actions.append(("suppress", uid, until, reason, actor_uid))
+        return {**self.alert, "status": "suppressed"}
+
+    def acknowledge_delivery(
+        self, uid, *, channel, receipt_id, actor_uid
+    ):
+        self.actions.append(("ack", uid, channel, receipt_id, actor_uid))
+        return {**self.alert, "delivery_status": "acknowledged"}
+
+    def list_incidents(self):
+        self.actions.append(("list_incidents",))
+        return [self.incident]
+
+    def incident_detail(self, uid):
+        self.actions.append(("incident_detail", uid))
+        return self.incident
+
+    def escalate_incident(self, uid, *, reason, actor_uid):
+        self.actions.append(("escalate", uid, reason, actor_uid))
+        return {**self.incident, "escalation_level": 2}
+
+    def close_incident(self, uid, payload, *, actor_uid):
+        self.actions.append(("close", uid, payload, actor_uid))
+        return {**self.incident, "status": "closed"}
+
+
+@pytest.fixture()
+def client(monkeypatch):
+    from flask import request
+
+    from app import create_app
+    from app.api.data_factory import observability_routes
+    from app.core.system import permissions
+
+    service = FakeDataObservabilityService()
+
+    def identity():
+        role = request.headers.get("Authorization", "").removeprefix("Bearer ")
+        if role not in {"viewer", "editor", "admin"}:
+            return None
+        suffix = {"viewer": "01", "editor": "02", "admin": "03"}[role]
+        return {
+            "id": f"01900000-0000-7000-8000-0000000055{suffix}",
+            "roles": [role],
+        }
+
+    monkeypatch.setattr(permissions, "authenticate_request", identity)
+    monkeypatch.setattr(
+        observability_routes,
+        "get_data_observability_service",
+        lambda: service,
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    return app.test_client(), service
+
+
+def headers(role):
+    return {"Authorization": f"Bearer {role}"}
+
+
+def test_viewer_reads_overview_slos_alerts_and_incident_evidence(client):
+    http, service = client
+    responses = [
+        http.get("/api/datafactory/observability/overview", headers=headers("viewer")),
+        http.get("/api/datafactory/observability/slos", headers=headers("viewer")),
+        http.get("/api/datafactory/observability/alerts", headers=headers("viewer")),
+        http.get(
+            f"/api/datafactory/observability/incidents/{INCIDENT_UID}",
+            headers=headers("viewer"),
+        ),
+    ]
+
+    assert [item.status_code for item in responses] == [200, 200, 200, 200]
+    assert set(responses[0].get_json()["data"]["layers"]) == {
+        "service",
+        "task",
+        "data",
+        "capacity",
+    }
+    assert responses[3].get_json()["data"]["impacts"]
+    assert ("incident_detail", INCIDENT_UID) in service.actions
+
+
+def test_editor_operates_collection_alert_delivery_and_incident_closure(client):
+    http, service = client
+    responses = [
+        http.post(
+            "/api/datafactory/observability/collect",
+            json={},
+            headers=headers("editor"),
+        ),
+        http.post(
+            f"/api/datafactory/observability/alerts/{ALERT_UID}/suppress",
+            json={
+                "until": "2026-08-01T12:00:00+08:00",
+                "reason": "计划维护",
+            },
+            headers=headers("editor"),
+        ),
+        http.post(
+            f"/api/datafactory/observability/alerts/{ALERT_UID}/acknowledge-delivery",
+            json={"channel": "console", "receipt_id": "receipt-1"},
+            headers=headers("editor"),
+        ),
+        http.post(
+            f"/api/datafactory/observability/incidents/{INCIDENT_UID}/escalate",
+            json={"reason": "影响扩大"},
+            headers=headers("editor"),
+        ),
+        http.post(
+            f"/api/datafactory/observability/incidents/{INCIDENT_UID}/close",
+            json={"root_cause": "已验证根因"},
+            headers=headers("editor"),
+        ),
+    ]
+
+    assert [item.status_code for item in responses] == [200, 200, 200, 200, 200]
+    assert responses[0].get_json()["data"]["processed"] == 2
+    assert any(item[0] == "ack" for item in service.actions)
+    assert any(item[0] == "close" for item in service.actions)
+
+
+def test_only_admin_manages_slo_policy(client):
+    http, service = client
+    payload = {
+        "code": "DEVICE_DELIVERY_SLO",
+        "sli_type": "delivery",
+    }
+
+    denied = http.post(
+        "/api/datafactory/observability/slos",
+        json=payload,
+        headers=headers("editor"),
+    )
+    created = http.post(
+        "/api/datafactory/observability/slos",
+        json=payload,
+        headers=headers("admin"),
+    )
+
+    assert denied.status_code == 403
+    assert created.status_code == 201
+    assert any(item[0] == "create_slo" for item in service.actions)

+ 59 - 0
tests/core/orchestration/test_data_observability_frontend_contract.py

@@ -0,0 +1,59 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+
+
+def test_data_observability_console_exposes_operational_incident_contract():
+    api = (ROOT / "frontend/src/api/dataObservability.js").read_text()
+    page = (
+        ROOT / "frontend/src/views/dataFactory/observability/index.vue"
+    ).read_text()
+    model = (
+        ROOT / "frontend/src/views/dataFactory/observability/model.js"
+    ).read_text()
+    routes = (ROOT / "frontend/src/router/routes.js").read_text()
+
+    for operation in (
+        "getObservabilityOverview",
+        "getSloPolicies",
+        "createSloPolicy",
+        "collectObservabilitySignals",
+        "getObservabilityAlerts",
+        "suppressObservabilityAlert",
+        "acknowledgeAlertDelivery",
+        "getDataIncidents",
+        "getDataIncident",
+        "escalateDataIncident",
+        "closeDataIncident",
+    ):
+        assert operation in api
+        assert operation in page
+
+    for capability in (
+        "数据可观测与事故",
+        "服务",
+        "任务",
+        "数据",
+        "容量",
+        "新鲜度",
+        "完整性",
+        "质量",
+        "交付",
+        "重复告警",
+        "抑制",
+        "升级",
+        "送达回执",
+        "影响资产",
+        "数据产品",
+        "业务域",
+        "用户影响",
+        "处置时间线",
+        "事故复盘",
+        "关闭证据",
+    ):
+        assert capability in page
+
+    assert "data-observability:operate" in model
+    assert "data-observability:manage" in model
+    assert "/dataFactory/observability" in routes
+    assert "dataFactory/observability" in routes

+ 30 - 0
tests/core/orchestration/test_data_observability_migration_contract.py

@@ -0,0 +1,30 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+
+
+def test_observability_migration_preserves_operational_evidence():
+    migration = (
+        ROOT
+        / "deployment/migrations/versions/20260731_410_data_observability.py"
+    ).read_text()
+    repository = (
+        ROOT / "app/core/events/data_observability_repository.py"
+    ).read_text()
+
+    for table in (
+        "data_slo_policies",
+        "data_observability_source_events",
+        "data_observability_alerts",
+        "data_incidents",
+        "data_incident_impacts",
+        "data_incident_timeline",
+        "data_incident_postmortems",
+    ):
+        assert f"CREATE TABLE public.{table}" in migration
+
+    assert "quality_sla_events" in repository
+    assert "active_metadata_runs" in repository
+    assert '"violated"' in repository
+    assert '"recovered"' in repository
+    assert "downgrade requires an approved archival migration" in migration

+ 431 - 0
tests/integration/test_data_observability_postgres.py

@@ -0,0 +1,431 @@
+from __future__ import annotations
+
+import os
+import uuid
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from sqlalchemy import text
+
+pytestmark = pytest.mark.integration
+
+
+def test_failed_collection_quality_sla_and_recovery_persist_incidents(
+    monkeypatch,
+):
+    database_url = os.environ.get("TEST_DATABASE_URL")
+    if not database_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+    monkeypatch.setenv("DATABASE_URL", database_url)
+
+    from app import create_app, db
+    from app.core.events.data_observability import DataObservabilityService
+    from app.core.events.data_observability_repository import (
+        SqlAlchemyDataObservabilityRepository,
+    )
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    actor_uid = str(uuid.uuid4())
+    owner_uid = str(uuid.uuid4())
+    source_uid = str(uuid.uuid4())
+    plan_uid = str(uuid.uuid4())
+    failed_run_uid = str(uuid.uuid4())
+    recovered_run_uid = str(uuid.uuid4())
+    asset_uid = str(uuid.uuid4())
+    template_uid = str(uuid.uuid4())
+    version_uid = str(uuid.uuid4())
+    quality_run_uids = [str(uuid.uuid4()), str(uuid.uuid4())]
+    quality_event_uids = [str(uuid.uuid4()), str(uuid.uuid4())]
+    now = datetime.now(UTC)
+    try:
+        with app.app_context():
+            for uid, label in ((actor_uid, "actor"), (owner_uid, "owner")):
+                db.session.execute(
+                    text(
+                        """
+                        INSERT INTO public.users (
+                            id, username, display_name, password_hash, status
+                        ) VALUES (
+                            CAST(:uid AS uuid), :username, :username,
+                            'p2-wp05-integration-hash', 'active'
+                        )
+                        """
+                    ),
+                    {
+                        "uid": uid,
+                        "username": f"wp05-{label}-{uid[:8]}",
+                    },
+                )
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.ingestion_sources (
+                        uid, source_type, name, config, permission_scope,
+                        status, created_by
+                    ) VALUES (
+                        CAST(:uid AS uuid), 'database', :name,
+                        '{}'::jsonb, '{}'::jsonb, 'active', :created_by
+                    )
+                    """
+                ),
+                {
+                    "uid": source_uid,
+                    "name": f"WP05 source {source_uid[:8]}",
+                    "created_by": actor_uid,
+                },
+            )
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_plans (
+                        uid, source_uid, name, source_kind, schedule_type,
+                        discovery_mode, scope, cursor_state, owner_uid,
+                        enabled, current_version, created_by
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:source_uid AS uuid), :name,
+                        'database', 'manual', 'snapshot',
+                        CAST(:scope AS jsonb), '{}'::jsonb,
+                        CAST(:owner_uid AS uuid), TRUE, 1,
+                        CAST(:created_by AS uuid)
+                    )
+                    """
+                ),
+                {
+                    "uid": plan_uid,
+                    "source_uid": source_uid,
+                    "name": "设备主数据采集",
+                    "scope": (
+                        '{"business_domain_uid":"device",'
+                        '"business_domain_name":"设备域",'
+                        '"data_product_uid":"device-ledger",'
+                        '"data_product_name":"设备台账",'
+                        '"user_impact_group_uid":"maintenance-ops",'
+                        '"user_impact_group_name":"设备运维人员"}'
+                    ),
+                    "owner_uid": owner_uid,
+                    "created_by": actor_uid,
+                },
+            )
+            for uid, batch, status, finished, failure in (
+                (
+                    failed_run_uid,
+                    "wp05-failed",
+                    "failed",
+                    now - timedelta(minutes=10),
+                    "SOURCE_TIMEOUT",
+                ),
+                (
+                    recovered_run_uid,
+                    "wp05-recovered",
+                    "completed",
+                    now - timedelta(minutes=5),
+                    None,
+                ),
+            ):
+                db.session.execute(
+                    text(
+                        """
+                        INSERT INTO public.active_metadata_runs (
+                            uid, plan_uid, batch_key, status, attempt_count,
+                            cursor_before, cursor_after, snapshot_hash,
+                            statistics, failure_code, failure_reason,
+                            actor_uid, started_at, finished_at
+                        ) VALUES (
+                            CAST(:uid AS uuid), CAST(:plan_uid AS uuid),
+                            :batch_key, :status, 1, '{}'::jsonb, '{}'::jsonb,
+                            :snapshot_hash, '{}'::jsonb, :failure_code,
+                            :failure_reason, CAST(:actor_uid AS uuid),
+                            :started_at, :finished_at
+                        )
+                        """
+                    ),
+                    {
+                        "uid": uid,
+                        "plan_uid": plan_uid,
+                        "batch_key": batch,
+                        "status": status,
+                        "snapshot_hash": "5" * 64 if status == "completed" else None,
+                        "failure_code": failure,
+                        "failure_reason": "source timeout" if failure else None,
+                        "actor_uid": actor_uid,
+                        "started_at": finished - timedelta(minutes=1),
+                        "finished_at": finished,
+                    },
+                )
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_assets (
+                        uid, source_uid, asset_key, namespace, name, asset_type,
+                        lifecycle_status, current_version, content_hash,
+                        snapshot, health, last_run_uid
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:source_uid AS uuid),
+                        :asset_key, 'maintenance', 'device_ledger', 'table',
+                        'active', 1, :content_hash, CAST(:snapshot AS jsonb),
+                        '{}'::jsonb, CAST(:last_run_uid AS uuid)
+                    )
+                    """
+                ),
+                {
+                    "uid": asset_uid,
+                    "source_uid": source_uid,
+                    "asset_key": f"{source_uid}:maintenance.device_ledger",
+                    "content_hash": "6" * 64,
+                    "snapshot": (
+                        '{"business_domain_uid":"device",'
+                        '"business_domain_name":"设备域",'
+                        '"user_impact_group_uid":"maintenance-ops",'
+                        '"user_impact_group_name":"设备运维人员"}'
+                    ),
+                    "last_run_uid": recovered_run_uid,
+                },
+            )
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.quality_templates (
+                        uid, code, name, owner_uid, status, current_version,
+                        created_by
+                    ) VALUES (
+                        CAST(:uid AS uuid), :code, 'WP05 quality',
+                        CAST(:owner_uid AS uuid), 'published', 1,
+                        CAST(:created_by AS uuid)
+                    )
+                    """
+                ),
+                {
+                    "uid": template_uid,
+                    "code": f"WP05_{template_uid[:8].upper()}",
+                    "owner_uid": owner_uid,
+                    "created_by": actor_uid,
+                },
+            )
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.quality_template_versions (
+                        uid, template_uid, version, status, definition,
+                        content_hash, created_by, published_by, published_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:template_uid AS uuid), 1,
+                        'published', '{}'::jsonb, :content_hash,
+                        CAST(:created_by AS uuid), CAST(:created_by AS uuid),
+                        :published_at
+                    )
+                    """
+                ),
+                {
+                    "uid": version_uid,
+                    "template_uid": template_uid,
+                    "content_hash": "7" * 64,
+                    "created_by": actor_uid,
+                    "published_at": now - timedelta(minutes=30),
+                },
+            )
+            db.session.execute(
+                text(
+                    """
+                    UPDATE public.quality_templates
+                    SET active_version_uid = CAST(:version_uid AS uuid)
+                    WHERE uid = CAST(:template_uid AS uuid)
+                    """
+                ),
+                {"version_uid": version_uid, "template_uid": template_uid},
+            )
+            for index, (run_uid, event_uid, event_status, score) in enumerate(
+                zip(
+                    quality_run_uids,
+                    quality_event_uids,
+                    ("violated", "recovered"),
+                    (68, 92),
+                    strict=True,
+                )
+            ):
+                created_at = now - timedelta(minutes=4 - index)
+                db.session.execute(
+                    text(
+                        """
+                        INSERT INTO public.quality_profile_runs (
+                            uid, template_uid, template_version_uid,
+                            template_hash, asset_uid, source_uid,
+                            business_domain_uid, batch_key, status, row_count,
+                            score, source_observed_at, comparison, profile,
+                            field_bindings, finding_count, deterministic,
+                            created_by, created_at
+                        ) VALUES (
+                            CAST(:uid AS uuid), CAST(:template_uid AS uuid),
+                            CAST(:version_uid AS uuid), :template_hash,
+                            CAST(:asset_uid AS uuid),
+                            CAST(:source_uid AS uuid), 'device', :batch_key,
+                            'success', 10, :score, :observed_at, '{}'::jsonb,
+                            '{}'::jsonb, '{}'::jsonb, 0, TRUE,
+                            CAST(:created_by AS uuid), :created_at
+                        )
+                        """
+                    ),
+                    {
+                        "uid": run_uid,
+                        "template_uid": template_uid,
+                        "version_uid": version_uid,
+                        "template_hash": "7" * 64,
+                        "asset_uid": asset_uid,
+                        "source_uid": source_uid,
+                        "batch_key": f"quality-{index}",
+                        "score": score,
+                        "observed_at": created_at,
+                        "created_by": actor_uid,
+                        "created_at": created_at,
+                    },
+                )
+                db.session.execute(
+                    text(
+                        """
+                        INSERT INTO public.quality_sla_events (
+                            uid, run_uid, asset_uid, sla_type, status,
+                            severity, actual, threshold, owner_uid,
+                            escalation_level, evidence, created_at
+                        ) VALUES (
+                            CAST(:uid AS uuid), CAST(:run_uid AS uuid),
+                            CAST(:asset_uid AS uuid), 'quality_score',
+                            :status, :severity, :actual, 80,
+                            CAST(:owner_uid AS uuid), :escalation_level,
+                            CAST(:evidence AS jsonb), :created_at
+                        )
+                        """
+                    ),
+                    {
+                        "uid": event_uid,
+                        "run_uid": run_uid,
+                        "asset_uid": asset_uid,
+                        "status": event_status,
+                        "severity": (
+                            "critical" if event_status == "violated" else "info"
+                        ),
+                        "actual": score,
+                        "owner_uid": owner_uid,
+                        "escalation_level": (
+                            1 if event_status == "violated" else 0
+                        ),
+                        "evidence": '{"deterministic":true}',
+                        "created_at": created_at,
+                    },
+                )
+            db.session.commit()
+
+            observability = DataObservabilityService(
+                SqlAlchemyDataObservabilityRepository(db.session),
+                commit=db.session.commit,
+                rollback=db.session.rollback,
+            )
+            result = observability.collect(actor_uid=actor_uid)
+            incidents = observability.list_incidents()
+
+            assert result["processed"] == 4
+            assert result["created_alerts"] == 2
+            assert result["recovered_alerts"] == 2
+            assert len(incidents) == 2
+            assert all(item["status"] == "monitoring" for item in incidents)
+            ingestion = next(
+                item for item in incidents if "采集" in item["title"]
+            )
+            detail = observability.incident_detail(ingestion["uid"])
+            assert detail["alerts"][0]["status"] == "recovered"
+            assert {
+                item["target_type"] for item in detail["impacts"]
+            } >= {
+                "service",
+                "business_domain",
+                "data_product",
+                "user_group",
+            }
+            closed = observability.close_incident(
+                ingestion["uid"],
+                {
+                    "root_cause": "源系统连接超时",
+                    "user_impact": "设备运维人员短时看到旧台账",
+                    "corrective_actions": ["增加连接超时监控"],
+                    "closure_evidence": [
+                        {"kind": "recovery_run", "ref": recovered_run_uid}
+                    ],
+                },
+                actor_uid=actor_uid,
+            )
+            assert closed["status"] == "closed"
+            assert closed["postmortem"]["closure_evidence"]
+    finally:
+        with app.app_context():
+            db.session.rollback()
+            db.session.execute(
+                text(
+                    """
+                    DELETE FROM public.data_incident_postmortems
+                    WHERE created_by = CAST(:actor_uid AS uuid);
+                    DELETE FROM public.data_incident_timeline
+                    WHERE actor_uid = CAST(:actor_uid AS uuid);
+                    DELETE FROM public.data_observability_source_events
+                    WHERE source_uid IN (
+                        :failed_run_uid, :recovered_run_uid,
+                        :quality_event_uid_1, :quality_event_uid_2
+                    );
+                    DELETE FROM public.data_observability_alerts
+                    WHERE owner_uid = CAST(:owner_uid AS uuid);
+                    DELETE FROM public.data_incident_impacts
+                    WHERE incident_uid IN (
+                        SELECT uid FROM public.data_incidents
+                        WHERE created_by = CAST(:actor_uid AS uuid)
+                    );
+                    DELETE FROM public.data_incidents
+                    WHERE created_by = CAST(:actor_uid AS uuid);
+                    DELETE FROM public.quality_sla_events
+                    WHERE uid IN (
+                        CAST(:quality_event_uid_1 AS uuid),
+                        CAST(:quality_event_uid_2 AS uuid)
+                    );
+                    DELETE FROM public.quality_profile_runs
+                    WHERE uid IN (
+                        CAST(:quality_run_uid_1 AS uuid),
+                        CAST(:quality_run_uid_2 AS uuid)
+                    );
+                    UPDATE public.quality_templates
+                    SET active_version_uid = NULL
+                    WHERE uid = CAST(:template_uid AS uuid);
+                    DELETE FROM public.quality_template_versions
+                    WHERE uid = CAST(:version_uid AS uuid);
+                    DELETE FROM public.quality_templates
+                    WHERE uid = CAST(:template_uid AS uuid);
+                    DELETE FROM public.active_metadata_assets
+                    WHERE uid = CAST(:asset_uid AS uuid);
+                    DELETE FROM public.active_metadata_runs
+                    WHERE uid IN (
+                        CAST(:failed_run_uid AS uuid),
+                        CAST(:recovered_run_uid AS uuid)
+                    );
+                    DELETE FROM public.active_metadata_plans
+                    WHERE uid = CAST(:plan_uid AS uuid);
+                    DELETE FROM public.ingestion_sources
+                    WHERE uid = CAST(:source_uid AS uuid);
+                    DELETE FROM public.users
+                    WHERE id IN (
+                        CAST(:actor_uid AS uuid), CAST(:owner_uid AS uuid)
+                    );
+                    """
+                ),
+                {
+                    "actor_uid": actor_uid,
+                    "owner_uid": owner_uid,
+                    "source_uid": source_uid,
+                    "plan_uid": plan_uid,
+                    "failed_run_uid": failed_run_uid,
+                    "recovered_run_uid": recovered_run_uid,
+                    "asset_uid": asset_uid,
+                    "template_uid": template_uid,
+                    "version_uid": version_uid,
+                    "quality_run_uid_1": quality_run_uids[0],
+                    "quality_run_uid_2": quality_run_uids[1],
+                    "quality_event_uid_1": quality_event_uids[0],
+                    "quality_event_uid_2": quality_event_uids[1],
+                },
+            )
+            db.session.commit()

+ 16 - 0
tests/test_permission_matrix.py

@@ -34,6 +34,10 @@ def test_fixed_role_permission_matrix_is_monotonic():
     assert "quality-issues:review" not in editor
     assert "quality-issues:review" in admin
     assert "device-observability:edit" in editor
+    assert "data-observability:read" in viewer
+    assert "data-observability:operate" in editor
+    assert "data-observability:manage" not in editor
+    assert "data-observability:manage" in admin
     assert "governance-audit:read" not in editor
     assert "governance-audit:read" in admin
     assert "governance-audit:seal" in admin
@@ -153,6 +157,18 @@ def test_data_development_paths_have_specific_write_policies():
         "/api/development/v1/governance-metrics/details",
         "GET",
     ) == ("governance:read",)
+    assert permission_for_request(
+        "/api/datafactory/observability/overview",
+        "GET",
+    ) == ("data-observability:read",)
+    assert permission_for_request(
+        "/api/datafactory/observability/collect",
+        "POST",
+    ) == ("data-observability:operate",)
+    assert permission_for_request(
+        "/api/datafactory/observability/slos",
+        "POST",
+    ) == ("data-observability:manage",)
     assert permission_for_request(
         "/api/system/governance-audit/events",
         "GET",