Browse Source

feat: add cross-domain quality operations

马小龙 3 weeks ago
parent
commit
3bb2482c0a
29 changed files with 7615 additions and 179 deletions
  1. 1 0
      app/api/data_rules/__init__.py
  2. 140 0
      app/api/data_rules/quality_routes.py
  3. 1142 0
      app/core/data_rules/quality_operations.py
  4. 568 0
      app/core/data_rules/quality_repository.py
  5. 1 0
      deployment/app/api/data_rules/__init__.py
  6. 140 0
      deployment/app/api/data_rules/quality_routes.py
  7. 1142 0
      deployment/app/core/data_rules/quality_operations.py
  8. 568 0
      deployment/app/core/data_rules/quality_repository.py
  9. 101 0
      deployment/migrations/versions/20260730_370_governance_domain_templates.py
  10. 199 0
      deployment/migrations/versions/20260730_380_active_metadata_lineage.py
  11. 162 0
      deployment/migrations/versions/20260731_390_semantic_governance.py
  12. 183 0
      deployment/migrations/versions/20260731_400_quality_operations.py
  13. 11 5
      docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md
  14. 9 9
      docs/FUNCTION_MODULE_CENSUS_20260726.md
  15. 22 0
      docs/architecture/DATA_MODEL.md
  16. 1265 155
      docs/architecture/OPENAPI.yaml
  17. 70 0
      docs/phase2/P2_WP04_QUALITY_OPERATIONS.md
  18. 18 0
      frontend/src/api/qualityOperations.js
  19. 12 0
      frontend/src/router/routes.js
  20. 1 0
      frontend/src/views/dataGovernance/development/index.vue
  21. 508 0
      frontend/src/views/dataGovernance/development/qualityOperations.vue
  22. 43 0
      frontend/src/views/dataGovernance/development/qualityOperationsModel.js
  23. 183 0
      migrations/versions/20260731_400_quality_operations.py
  24. 4 5
      scripts/generate_openapi.py
  25. 440 0
      tests/core/data_rules/test_quality_operations.py
  26. 188 0
      tests/core/data_rules/test_quality_operations_api.py
  27. 59 0
      tests/core/data_rules/test_quality_operations_frontend_contract.py
  28. 433 0
      tests/integration/test_quality_operations_postgres.py
  29. 2 5
      tests/test_architecture_artifacts.py

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

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

+ 140 - 0
app/api/data_rules/quality_routes.py

@@ -0,0 +1,140 @@
+"""HTTP boundary for generic, deterministic quality operations."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.data_rules import bp
+from app.models.result import failed, success
+
+
+def get_quality_operations_service():
+    from app.core.data_rules.quality_operations import QualityOperationsService
+    from app.core.data_rules.quality_repository import (
+        SqlAlchemyQualityOperationsRepository,
+    )
+
+    return QualityOperationsService(
+        SqlAlchemyQualityOperationsRepository(db.session),
+        publish_authorizer=lambda _actor_uid: None,
+        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("/quality-operations/templates")
+def list_quality_templates():
+    try:
+        return jsonify(
+            success(get_quality_operations_service().list_templates())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/templates")
+def create_quality_template():
+    try:
+        record = get_quality_operations_service().create_template(
+            _payload(),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/templates/<template_uid>/revisions")
+def revise_quality_template(template_uid):
+    try:
+        payload = _payload()
+        record = get_quality_operations_service().revise_template(
+            template_uid,
+            payload.get("definition"),
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/templates/<template_uid>/publish")
+def publish_quality_template(template_uid):
+    try:
+        payload = _payload()
+        record = get_quality_operations_service().publish_template(
+            template_uid,
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/execute")
+def execute_quality_profile():
+    try:
+        record = get_quality_operations_service().execute(
+            _payload(),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/quality-operations/runs")
+def list_quality_runs():
+    try:
+        records = get_quality_operations_service().list_runs(
+            request.args.get("asset_uid")
+        )
+        return jsonify(success(records)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/quality-operations/runs/<run_uid>")
+def get_quality_run(run_uid):
+    try:
+        return jsonify(
+            success(get_quality_operations_service().get_run(run_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/quality-operations/assets/<asset_uid>/trend")
+def get_quality_trend(asset_uid):
+    try:
+        return jsonify(
+            success(get_quality_operations_service().trend(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)

+ 1142 - 0
app/core/data_rules/quality_operations.py

@@ -0,0 +1,1142 @@
+"""Deterministic, cross-domain data quality profiling and SLA operations."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import math
+import re
+import statistics
+import uuid
+from collections import Counter
+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
+
+MAX_RECORDS = 5_000
+MAX_FIELDS = 200
+MAX_CELLS = 200_000
+MAX_INLINE_CHARACTERS = 8_000_000
+MAX_SCALAR_CHARACTERS = 10_000
+MAX_ROLES = 30
+MAX_SAMPLE_LIMIT = 20
+ROLE_CHECKS = frozenset({"unique", "pattern", "distribution", "outlier"})
+TEMPLATE_KEYS = frozenset(
+    {"schema_version", "field_roles", "thresholds", "sample_limit"}
+)
+ROLE_KEYS = frozenset({"required", "checks"})
+THRESHOLD_KEYS = frozenset(
+    {
+        "completeness_min",
+        "uniqueness_min",
+        "pattern",
+        "volume_change_max_ratio",
+        "distribution_drift_max",
+        "freshness_max_seconds",
+        "quality_score_min",
+    }
+)
+SECRET_TOKENS = frozenset(
+    {
+        "apikey",
+        "authorization",
+        "connectionstring",
+        "credential",
+        "dsn",
+        "password",
+        "secret",
+        "token",
+    }
+)
+ROLE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
+FIELD_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]{0,199}$")
+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: frozenset[str] | set[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 copy.deepcopy(value)
+
+
+def _bounded_string(value: Any, label: str, maximum: int) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    normalized = value.strip()
+    if len(normalized) > maximum:
+        raise ValueError(f"{label} exceeds {maximum} characters")
+    return normalized
+
+
+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 _ratio(value: Any, label: str, *, maximum: float = 1.0) -> float:
+    if isinstance(value, bool):
+        raise ValueError(f"{label} must be numeric")
+    try:
+        number = float(value)
+    except (TypeError, ValueError) as error:
+        raise ValueError(f"{label} must be numeric") from error
+    if not math.isfinite(number) or number < 0 or number > maximum:
+        raise ValueError(f"{label} must be between 0 and {maximum}")
+    return number
+
+
+def _positive_number(value: Any, label: str, *, maximum: float) -> float:
+    if isinstance(value, bool):
+        raise ValueError(f"{label} must be numeric")
+    try:
+        number = float(value)
+    except (TypeError, ValueError) as error:
+        raise ValueError(f"{label} must be numeric") from error
+    if not math.isfinite(number) or number <= 0 or number > maximum:
+        raise ValueError(f"{label} must be between 0 and {maximum}")
+    return number
+
+
+def _canonical(value: Any) -> str:
+    try:
+        return json.dumps(
+            value,
+            ensure_ascii=False,
+            sort_keys=True,
+            separators=(",", ":"),
+        )
+    except (TypeError, ValueError) as error:
+        raise ValueError("quality data must be JSON serializable") from error
+
+
+def _content_hash(value: Any) -> str:
+    return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
+
+
+def _parse_datetime(value: Any, label: str) -> datetime:
+    if isinstance(value, datetime):
+        parsed = value
+    else:
+        try:
+            parsed = 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 parsed.tzinfo is None:
+        raise ValueError(f"{label} must include a timezone")
+    return parsed
+
+
+def validate_quality_template(value: Any) -> dict[str, Any]:
+    """Validate a domain-neutral quality template with semantic field roles."""
+
+    template = _closed_object(value, TEMPLATE_KEYS, "quality template")
+    if template.get("schema_version") != "1.0":
+        raise ValueError("quality template schema_version must be 1.0")
+    raw_roles = template.get("field_roles")
+    if not isinstance(raw_roles, dict) or not 1 <= len(raw_roles) <= MAX_ROLES:
+        raise ValueError("field_roles must contain between 1 and 30 roles")
+    roles = {}
+    for raw_name, raw_role in raw_roles.items():
+        name = str(raw_name)
+        if not ROLE_PATTERN.fullmatch(name):
+            raise ValueError("quality field role name is invalid")
+        role = _closed_object(raw_role, ROLE_KEYS, f"field role {name}")
+        if not isinstance(role.get("required"), bool):
+            raise ValueError(f"field role {name} required must be a boolean")
+        checks = role.get("checks")
+        if not isinstance(checks, list):
+            raise ValueError(f"field role {name} checks must be an array")
+        normalized_checks = sorted({str(item) for item in checks})
+        if not set(normalized_checks) <= ROLE_CHECKS:
+            raise ValueError(f"field role {name} contains unsupported checks")
+        roles[name] = {
+            "required": role["required"],
+            "checks": normalized_checks,
+        }
+    thresholds = _closed_object(
+        template.get("thresholds"),
+        THRESHOLD_KEYS,
+        "quality thresholds",
+    )
+    required_thresholds = THRESHOLD_KEYS
+    missing = sorted(required_thresholds - set(thresholds))
+    if missing:
+        raise ValueError(
+            f"quality thresholds are missing fields: {', '.join(missing)}"
+        )
+    thresholds["completeness_min"] = _ratio(
+        thresholds["completeness_min"], "completeness_min"
+    )
+    thresholds["uniqueness_min"] = _ratio(
+        thresholds["uniqueness_min"], "uniqueness_min"
+    )
+    thresholds["volume_change_max_ratio"] = _ratio(
+        thresholds["volume_change_max_ratio"], "volume_change_max_ratio"
+    )
+    thresholds["distribution_drift_max"] = _ratio(
+        thresholds["distribution_drift_max"], "distribution_drift_max"
+    )
+    thresholds["freshness_max_seconds"] = _positive_number(
+        thresholds["freshness_max_seconds"],
+        "freshness_max_seconds",
+        maximum=31_536_000,
+    )
+    thresholds["quality_score_min"] = _ratio(
+        thresholds["quality_score_min"],
+        "quality_score_min",
+        maximum=100,
+    )
+    pattern = _bounded_string(thresholds["pattern"], "pattern", 120)
+    if (
+        not pattern.startswith("^")
+        or not pattern.endswith("$")
+        or "(?" in pattern
+        or re.search(r"\\[1-9]", pattern)
+        or re.search(r"\([^)]*[+*{][^)]*\)[+*{]", pattern)
+        or ".*.*" in pattern
+    ):
+        raise ValueError("pattern must be anchored and cannot use advanced groups")
+    try:
+        re.compile(pattern)
+    except re.error as error:
+        raise ValueError("pattern is invalid") from error
+    thresholds["pattern"] = pattern
+    sample_limit = template.get("sample_limit")
+    if (
+        isinstance(sample_limit, bool)
+        or not isinstance(sample_limit, int)
+        or not 1 <= sample_limit <= MAX_SAMPLE_LIMIT
+    ):
+        raise ValueError("sample_limit must be between 1 and 20")
+    return {
+        "schema_version": "1.0",
+        "field_roles": roles,
+        "thresholds": thresholds,
+        "sample_limit": sample_limit,
+    }
+
+
+def _scalar(value: Any) -> bool:
+    return value is None or isinstance(value, (str, int, float, bool))
+
+
+def _normalized_records(value: Any) -> list[dict[str, Any]]:
+    if not isinstance(value, list) or not 1 <= len(value) <= MAX_RECORDS:
+        raise ValueError("records must contain between 1 and 5000 rows")
+    if sum(len(item) for item in value if isinstance(item, dict)) > MAX_CELLS:
+        raise ValueError("quality records contain too many cells")
+    field_names = set()
+    records = []
+    inline_characters = 0
+    for raw in value:
+        if not isinstance(raw, dict):
+            raise ValueError("each quality record must be an object")
+        if len(raw) > MAX_FIELDS:
+            raise ValueError("a quality record contains too many fields")
+        record = {}
+        for raw_field, item in raw.items():
+            field = str(raw_field)
+            if not FIELD_PATTERN.fullmatch(field):
+                raise ValueError("quality record field name is invalid")
+            if not _scalar(item):
+                raise ValueError("quality record values must be scalar")
+            if isinstance(item, float) and not math.isfinite(item):
+                raise ValueError("quality record numeric values must be finite")
+            if (
+                isinstance(item, int)
+                and not isinstance(item, bool)
+                and item.bit_length() > 256
+            ):
+                raise ValueError("quality record integer value is too large")
+            if isinstance(item, str):
+                if len(item) > MAX_SCALAR_CHARACTERS:
+                    raise ValueError("quality record scalar value is too large")
+                inline_characters += len(item)
+                if inline_characters > MAX_INLINE_CHARACTERS:
+                    raise ValueError("quality records inline content is too large")
+            record[field] = item
+            field_names.add(field)
+        records.append(record)
+    if len(field_names) > MAX_FIELDS:
+        raise ValueError("quality records contain too many fields")
+    return records
+
+
+def _value_token(value: Any) -> str:
+    digest = hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()[:16]
+    return f"sha256:{digest}"
+
+
+def _type_name(value: Any) -> str:
+    if value is None:
+        return "null"
+    if isinstance(value, bool):
+        return "boolean"
+    if isinstance(value, int):
+        return "integer"
+    if isinstance(value, float):
+        return "number"
+    return "string"
+
+
+def _field_profile(
+    records: list[dict[str, Any]],
+    field: str,
+    *,
+    sample_limit: int,
+) -> dict[str, Any]:
+    values = [item.get(field) for item in records]
+    populated = [item for item in values if item is not None and item != ""]
+    counts = Counter(_canonical(item) for item in populated)
+    originals = {}
+    for item in populated:
+        originals.setdefault(_canonical(item), item)
+    top_values = [
+        {"value_token": _value_token(originals[key]), "count": count}
+        for key, count in sorted(
+            counts.items(), key=lambda item: (-item[1], item[0])
+        )[:sample_limit]
+    ]
+    type_counts = Counter(_type_name(item) for item in values)
+    return {
+        "field_name": field,
+        "row_count": len(records),
+        "null_count": len(values) - len(populated),
+        "null_rate": round(
+            (len(values) - len(populated)) / len(records),
+            6,
+        ),
+        "completeness_rate": round(len(populated) / len(records), 6),
+        "distinct_count": len(counts),
+        "uniqueness_rate": round(
+            len(counts) / len(populated), 6
+        )
+        if populated
+        else 0.0,
+        "type_counts": dict(sorted(type_counts.items())),
+        "top_values": top_values,
+        "sample_tokens": sorted({_value_token(item) for item in populated})[
+            :sample_limit
+        ],
+    }
+
+
+def _distribution(profile: dict[str, Any]) -> dict[str, float]:
+    total = sum(item["count"] for item in profile["top_values"])
+    if total <= 0:
+        return {}
+    return {
+        item["value_token"]: item["count"] / total
+        for item in profile["top_values"]
+    }
+
+
+def _distribution_distance(
+    current: dict[str, float],
+    previous: dict[str, float],
+) -> float:
+    keys = set(current) | set(previous)
+    return round(
+        0.5
+        * sum(abs(current.get(key, 0.0) - previous.get(key, 0.0)) for key in keys),
+        6,
+    )
+
+
+def _schema_signature(profiles: dict[str, dict[str, Any]]) -> dict[str, Any]:
+    return {
+        field: {
+            "types": sorted(
+                key for key in profile["type_counts"] if key != "null"
+            ),
+        }
+        for field, profile in sorted(profiles.items())
+    }
+
+
+def _outlier_tokens(values: list[Any], sample_limit: int) -> list[str]:
+    numeric = [
+        float(item)
+        for item in values
+        if isinstance(item, (int, float)) and not isinstance(item, bool)
+    ]
+    if len(numeric) < 4:
+        return []
+    median = statistics.median(numeric)
+    deviations = [abs(item - median) for item in numeric]
+    mad = statistics.median(deviations)
+    if mad == 0:
+        outliers = [item for item in numeric if item != median]
+    else:
+        outliers = [
+            item
+            for item in numeric
+            if 0.6745 * abs(item - median) / mad > 3.5
+        ]
+    return sorted({_value_token(item) for item in outliers})[:sample_limit]
+
+
+class QualityOperationsService:
+    """Govern quality templates and persist deterministic operational evidence."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        publish_authorizer: Callable[[str], None],
+        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.publish_authorizer = publish_authorizer
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def create_template(
+        self,
+        payload: Any,
+        *,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed_object(
+            payload,
+            {"code", "name", "owner_uid", "definition"},
+            "quality template request",
+        )
+        code = _bounded_string(body.get("code"), "code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("quality template code is invalid")
+        name = _bounded_string(body.get("name"), "name", 300)
+        owner_uid = _uid(body.get("owner_uid"), "owner_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        definition = validate_quality_template(body.get("definition"))
+        now = self.now_factory()
+        template_uid = self.uid_factory()
+        version_uid = self.uid_factory()
+        template = {
+            "uid": template_uid,
+            "code": code,
+            "name": name,
+            "owner_uid": owner_uid,
+            "status": "draft",
+            "current_version": 1,
+            "active_version_uid": None,
+            "created_by": actor,
+            "created_at": now.isoformat(),
+            "updated_at": now.isoformat(),
+        }
+        version = {
+            "uid": version_uid,
+            "template_uid": template_uid,
+            "version": 1,
+            "status": "draft",
+            "definition": definition,
+            "content_hash": _content_hash(definition),
+            "created_by": actor,
+            "created_at": now.isoformat(),
+            "published_by": None,
+            "published_at": None,
+        }
+        try:
+            self.repository.create_template(template, version)
+            self.commit()
+            return {**template, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def revise_template(
+        self,
+        template_uid: str,
+        definition: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(template_uid, "template_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        template = self.repository.get_template(uid, for_update=True)
+        if template is None:
+            raise LookupError("quality template was not found")
+        if int(template["current_version"]) != int(expected_version):
+            raise RuntimeError("quality template version conflict")
+        normalized = validate_quality_template(definition)
+        version_number = int(template["current_version"]) + 1
+        now = self.now_factory()
+        version = {
+            "uid": self.uid_factory(),
+            "template_uid": uid,
+            "version": version_number,
+            "status": "draft",
+            "definition": normalized,
+            "content_hash": _content_hash(normalized),
+            "created_by": actor,
+            "created_at": now.isoformat(),
+            "published_by": None,
+            "published_at": None,
+        }
+        updated = {
+            **template,
+            "status": "draft",
+            "current_version": version_number,
+            "updated_at": now.isoformat(),
+        }
+        try:
+            self.repository.save_template_version(updated, version)
+            self.commit()
+            return {**updated, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def publish_template(
+        self,
+        template_uid: str,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(template_uid, "template_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        self.publish_authorizer(actor)
+        template = self.repository.get_template(uid, for_update=True)
+        if template is None:
+            raise LookupError("quality template was not found")
+        if int(template["current_version"]) != int(expected_version):
+            raise RuntimeError("quality template version conflict")
+        version = next(
+            (
+                item
+                for item in self.repository.versions_for_template(uid)
+                if int(item["version"]) == int(expected_version)
+            ),
+            None,
+        )
+        if version is None:
+            raise LookupError("quality template version was not found")
+        now = self.now_factory()
+        published_version = {
+            **version,
+            "status": "published",
+            "published_by": actor,
+            "published_at": now.isoformat(),
+        }
+        published = {
+            **template,
+            "status": "published",
+            "active_version_uid": version["uid"],
+            "updated_at": now.isoformat(),
+        }
+        try:
+            self.repository.publish_template(published, published_version)
+            self.commit()
+            return {**published, "active_version": published_version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_templates(self) -> list[dict[str, Any]]:
+        records = []
+        for template in self.repository.list_templates():
+            versions = self.repository.versions_for_template(template["uid"])
+            records.append(
+                {
+                    **template,
+                    "latest_version": versions[0] if versions else None,
+                    "active_version": next(
+                        (
+                            item
+                            for item in versions
+                            if item["uid"] == template.get("active_version_uid")
+                        ),
+                        None,
+                    ),
+                }
+            )
+        return records
+
+    def _binding(
+        self,
+        definition: dict[str, Any],
+        value: Any,
+    ) -> dict[str, str]:
+        if not isinstance(value, dict):
+            raise ValueError("field_bindings must be an object")
+        if set(value) != set(definition["field_roles"]):
+            raise ValueError("field_bindings must match template field roles")
+        result = {}
+        for role, field in value.items():
+            if not FIELD_PATTERN.fullmatch(str(field)):
+                raise ValueError("field binding is invalid")
+            result[str(role)] = str(field)
+        if len(set(result.values())) != len(result):
+            raise ValueError("field bindings must be unique")
+        return result
+
+    def execute(
+        self,
+        payload: Any,
+        *,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed_object(
+            payload,
+            {
+                "template_uid",
+                "asset_uid",
+                "batch_key",
+                "field_bindings",
+                "source_observed_at",
+                "records",
+            },
+            "quality execution request",
+        )
+        actor = _uid(actor_uid, "actor_uid")
+        template_uid = _uid(body.get("template_uid"), "template_uid")
+        asset_uid = _uid(body.get("asset_uid"), "asset_uid")
+        batch_key = _bounded_string(body.get("batch_key"), "batch_key", 160)
+        for existing in self.repository.list_runs(asset_uid):
+            if existing["batch_key"] == batch_key:
+                return existing
+        template = self.repository.get_template(template_uid)
+        if template is None or template.get("status") != "published":
+            raise RuntimeError("quality template must be published before execution")
+        version = self.repository.get_template_version(
+            template.get("active_version_uid")
+        )
+        if version is None or version.get("status") != "published":
+            raise RuntimeError("published quality template version was not found")
+        definition = validate_quality_template(version["definition"])
+        bindings = self._binding(definition, body.get("field_bindings"))
+        records = _normalized_records(body.get("records"))
+        bound_fields = set(bindings.values())
+        missing_fields = sorted(
+            field for field in bound_fields if all(field not in row for row in records)
+        )
+        if missing_fields:
+            raise ValueError(
+                f"bound fields are absent from records: {', '.join(missing_fields)}"
+            )
+        asset = self.repository.get_asset(asset_uid)
+        if asset is None:
+            raise LookupError("active metadata asset was not found")
+        source_observed_at = _parse_datetime(
+            body.get("source_observed_at"),
+            "source_observed_at",
+        )
+        now = self.now_factory()
+        if source_observed_at > now:
+            raise ValueError("source_observed_at cannot be in the future")
+        profiles = {
+            field: _field_profile(
+                records,
+                field,
+                sample_limit=definition["sample_limit"],
+            )
+            for field in sorted({key for row in records for key in row})
+        }
+        compiled_pattern = re.compile(definition["thresholds"]["pattern"])
+        for role, role_spec in definition["field_roles"].items():
+            if "pattern" not in role_spec["checks"]:
+                continue
+            field = bindings[role]
+            values = [
+                row.get(field)
+                for row in records
+                if row.get(field) not in (None, "")
+            ]
+            mismatches = [
+                item
+                for item in values
+                if not compiled_pattern.fullmatch(str(item))
+            ]
+            profiles[field]["pattern"] = {
+                "evaluated_count": len(values),
+                "mismatch_count": len(mismatches),
+                "match_rate": (
+                    round((len(values) - len(mismatches)) / len(values), 6)
+                    if values
+                    else 0.0
+                ),
+                "mismatch_sample_tokens": sorted(
+                    {_value_token(item) for item in mismatches}
+                )[: definition["sample_limit"]],
+            }
+        previous = self.repository.latest_run(asset_uid)
+        root_cause = self.repository.related_evidence(asset)
+        root_cause["responsibility"] = {
+            "owner_uid": template["owner_uid"],
+            "source": "quality_template",
+        }
+        run_uid = self.uid_factory()
+        findings = self._findings(
+            run_uid=run_uid,
+            template=template,
+            definition=definition,
+            bindings=bindings,
+            records=records,
+            profiles=profiles,
+            previous=previous,
+            source_observed_at=source_observed_at,
+            now=now,
+            root_cause=root_cause,
+        )
+        score = round(max(0.0, 100.0 - len(findings) * 12.0), 2)
+        comparison = self._comparison(
+            row_count=len(records),
+            score=score,
+            previous=previous,
+        )
+        run = {
+            "uid": run_uid,
+            "template_uid": template_uid,
+            "template_version_uid": version["uid"],
+            "template_hash": version["content_hash"],
+            "asset_uid": asset_uid,
+            "source_uid": asset["source_uid"],
+            "business_domain_uid": asset.get("business_domain_uid"),
+            "batch_key": batch_key,
+            "status": "success",
+            "row_count": len(records),
+            "score": score,
+            "source_observed_at": source_observed_at.isoformat(),
+            "previous_run_uid": previous["uid"] if previous else None,
+            "comparison": comparison,
+            "profile": {
+                "fields": profiles,
+                "schema": _schema_signature(profiles),
+            },
+            "field_bindings": bindings,
+            "finding_count": len(findings),
+            "deterministic": True,
+            "created_by": actor,
+            "created_at": now.isoformat(),
+        }
+        events = self._sla_events(run, definition, findings)
+        metrics = [
+            {
+                "uid": self.uid_factory(),
+                "run_uid": run_uid,
+                "created_at": now.isoformat(),
+                **profile,
+            }
+            for profile in profiles.values()
+        ]
+        try:
+            self.repository.save_run(
+                run,
+                metrics,
+                findings,
+                events,
+            )
+            self.commit()
+            return run
+        except Exception:
+            self.rollback()
+            raise
+
+    def _finding(
+        self,
+        *,
+        run_uid: str,
+        template_uid: str,
+        asset_uid: str,
+        finding_type: str,
+        field_name: str | None,
+        severity: str,
+        actual: Any,
+        expected: Any,
+        root_cause: dict[str, Any],
+    ) -> dict[str, Any]:
+        recurrence_key = _content_hash(
+            {
+                "template_uid": template_uid,
+                "asset_uid": asset_uid,
+                "finding_type": finding_type,
+                "field_name": field_name,
+            }
+        )
+        occurrence = self.repository.recurrence_count(recurrence_key) + 1
+        return {
+            "uid": self.uid_factory(),
+            "run_uid": run_uid,
+            "asset_uid": asset_uid,
+            "finding_type": finding_type,
+            "field_name": field_name,
+            "severity": severity,
+            "status": "open",
+            "actual": copy.deepcopy(actual),
+            "expected": copy.deepcopy(expected),
+            "recurrence_key": recurrence_key,
+            "occurrence_number": occurrence,
+            "evidence": {
+                "deterministic": True,
+                "root_cause": copy.deepcopy(root_cause),
+            },
+            "created_at": self.now_factory().isoformat(),
+        }
+
+    def _findings(
+        self,
+        *,
+        run_uid,
+        template,
+        definition,
+        bindings,
+        records,
+        profiles,
+        previous,
+        source_observed_at,
+        now,
+        root_cause,
+    ):
+        thresholds = definition["thresholds"]
+        findings = []
+
+        def add(kind, field, actual, expected, severity="error"):
+            findings.append(
+                self._finding(
+                    run_uid=run_uid,
+                    template_uid=template["uid"],
+                    asset_uid=root_cause["asset"]["uid"],
+                    finding_type=kind,
+                    field_name=field,
+                    severity=severity,
+                    actual=actual,
+                    expected=expected,
+                    root_cause=root_cause,
+                )
+            )
+
+        for role, role_spec in definition["field_roles"].items():
+            field = bindings[role]
+            profile = profiles[field]
+            if (
+                role_spec["required"]
+                and profile["completeness_rate"]
+                < thresholds["completeness_min"]
+            ):
+                add(
+                    "completeness",
+                    field,
+                    profile["completeness_rate"],
+                    {"minimum": thresholds["completeness_min"]},
+                )
+            if (
+                "unique" in role_spec["checks"]
+                and profile["uniqueness_rate"] < thresholds["uniqueness_min"]
+            ):
+                add(
+                    "uniqueness",
+                    field,
+                    profile["uniqueness_rate"],
+                    {"minimum": thresholds["uniqueness_min"]},
+                )
+                duplicate_count = sum(
+                    count - 1
+                    for count in Counter(
+                        _canonical(row.get(field))
+                        for row in records
+                        if row.get(field) not in (None, "")
+                    ).values()
+                    if count > 1
+                )
+                if duplicate_count:
+                    add(
+                        "duplicate",
+                        field,
+                        {"duplicate_row_count": duplicate_count},
+                        {"duplicate_row_count": 0},
+                    )
+            if "pattern" in role_spec["checks"]:
+                pattern_profile = profile["pattern"]
+                if pattern_profile["mismatch_count"]:
+                    add(
+                        "pattern",
+                        field,
+                        {
+                            "mismatch_count": pattern_profile["mismatch_count"],
+                            "match_rate": pattern_profile["match_rate"],
+                            "sample_tokens": pattern_profile[
+                                "mismatch_sample_tokens"
+                            ],
+                        },
+                        {"pattern": thresholds["pattern"]},
+                    )
+            if "outlier" in role_spec["checks"]:
+                tokens = _outlier_tokens(
+                    [row.get(field) for row in records],
+                    definition["sample_limit"],
+                )
+                if tokens:
+                    add(
+                        "outlier",
+                        field,
+                        {"sample_tokens": tokens},
+                        {"outlier_count": 0},
+                        severity="warning",
+                    )
+        freshness_seconds = max(0.0, (now - source_observed_at).total_seconds())
+        if freshness_seconds > thresholds["freshness_max_seconds"]:
+            add(
+                "freshness",
+                None,
+                {"seconds": round(freshness_seconds, 3)},
+                {"maximum_seconds": thresholds["freshness_max_seconds"]},
+                severity="critical",
+            )
+        if previous is not None:
+            previous_count = int(previous["row_count"])
+            ratio = (
+                abs(len(records) - previous_count) / previous_count
+                if previous_count
+                else 1.0
+            )
+            if ratio > thresholds["volume_change_max_ratio"]:
+                add(
+                    "volume",
+                    None,
+                    {"row_count": len(records), "change_ratio": round(ratio, 6)},
+                    {
+                        "previous_row_count": previous_count,
+                        "maximum_change_ratio": thresholds[
+                            "volume_change_max_ratio"
+                        ],
+                    },
+                )
+            previous_profiles = previous.get("profile", {}).get("fields", {})
+            for role, role_spec in definition["field_roles"].items():
+                if "distribution" not in role_spec["checks"]:
+                    continue
+                field = bindings[role]
+                previous_profile = previous_profiles.get(field)
+                if previous_profile is None:
+                    continue
+                distance = _distribution_distance(
+                    _distribution(profiles[field]),
+                    _distribution(previous_profile),
+                )
+                if distance > thresholds["distribution_drift_max"]:
+                    add(
+                        "distribution",
+                        field,
+                        {"distance": distance},
+                        {
+                            "maximum_distance": thresholds[
+                                "distribution_drift_max"
+                            ]
+                        },
+                        severity="warning",
+                    )
+            previous_schema = previous.get("profile", {}).get("schema", {})
+            current_schema = _schema_signature(profiles)
+            if current_schema != previous_schema:
+                add(
+                    "schema",
+                    None,
+                    {
+                        "current_hash": _content_hash(current_schema),
+                        "changed": True,
+                    },
+                    {"previous_hash": _content_hash(previous_schema)},
+                )
+        return findings
+
+    @staticmethod
+    def _comparison(*, row_count, score, previous):
+        if previous is None:
+            return {
+                "row_count_change_ratio": None,
+                "score_delta": None,
+                "period": "baseline",
+            }
+        previous_count = int(previous["row_count"])
+        change = (
+            (row_count - previous_count) / previous_count
+            if previous_count
+            else 1.0
+        )
+        return {
+            "row_count_change_ratio": round(change, 6),
+            "score_delta": round(score - float(previous["score"]), 2),
+            "period": "previous_run",
+        }
+
+    def _sla_events(self, run, definition, findings):
+        thresholds = definition["thresholds"]
+        freshness_finding = next(
+            (item for item in findings if item["finding_type"] == "freshness"),
+            None,
+        )
+        checks = [
+            (
+                "freshness",
+                freshness_finding is None,
+                (
+                    0
+                    if freshness_finding is None
+                    else freshness_finding["actual"]["seconds"]
+                ),
+                thresholds["freshness_max_seconds"],
+            ),
+            (
+                "quality_score",
+                run["score"] >= thresholds["quality_score_min"],
+                run["score"],
+                thresholds["quality_score_min"],
+            ),
+        ]
+        events = []
+        for sla_type, met, actual, threshold in checks:
+            previous = self.repository.latest_sla_event(
+                run["asset_uid"], sla_type
+            )
+            status = "met" if met else "violated"
+            if met and previous and previous["status"] == "violated":
+                status = "recovered"
+            recurrence = max(
+                (
+                    item["occurrence_number"]
+                    for item in findings
+                    if (
+                        sla_type == "quality_score"
+                        or item["finding_type"] == "freshness"
+                    )
+                ),
+                default=0,
+            )
+            events.append(
+                {
+                    "uid": self.uid_factory(),
+                    "run_uid": run["uid"],
+                    "asset_uid": run["asset_uid"],
+                    "sla_type": sla_type,
+                    "status": status,
+                    "severity": "critical" if not met else "info",
+                    "actual": actual,
+                    "threshold": threshold,
+                    "owner_uid": (
+                        self.repository.get_template(run["template_uid"])[
+                            "owner_uid"
+                        ]
+                    ),
+                    "escalation_level": (
+                        min(3, max(1, recurrence)) if not met else 0
+                    ),
+                    "evidence": {
+                        "run_uid": run["uid"],
+                        "deterministic": True,
+                    },
+                    "created_at": run["created_at"],
+                }
+            )
+        return events
+
+    def trend(self, asset_uid: str) -> dict[str, Any]:
+        uid = _uid(asset_uid, "asset_uid")
+        runs = sorted(
+            self.repository.list_runs(uid),
+            key=lambda item: (item["created_at"], item["uid"]),
+        )
+        points = [
+            {
+                "run_uid": item["uid"],
+                "created_at": item["created_at"],
+                "row_count": item["row_count"],
+                "score": item["score"],
+                "finding_count": item["finding_count"],
+            }
+            for item in runs
+        ]
+        latest = (
+            {
+                **runs[-1]["comparison"],
+                "run_uid": runs[-1]["uid"],
+            }
+            if runs
+            else None
+        )
+        return {
+            "asset_uid": uid,
+            "points": points,
+            "latest": latest,
+            "year_over_year": self._period_delta(runs, days=365),
+            "month_over_month": self._period_delta(runs, days=30),
+        }
+
+    @staticmethod
+    def _period_delta(runs: list[dict[str, Any]], *, days: int):
+        if len(runs) < 2:
+            return None
+        latest = runs[-1]
+        latest_at = _parse_datetime(latest["created_at"], "created_at")
+        candidates = [
+            item
+            for item in runs[:-1]
+            if (
+                latest_at - _parse_datetime(item["created_at"], "created_at")
+            ).days
+            >= days
+        ]
+        if not candidates:
+            return None
+        baseline = candidates[-1]
+        return {
+            "baseline_run_uid": baseline["uid"],
+            "score_delta": round(
+                float(latest["score"]) - float(baseline["score"]), 2
+            ),
+            "row_count_delta": int(latest["row_count"])
+            - int(baseline["row_count"]),
+        }
+
+    def get_run(self, run_uid: str) -> dict[str, Any]:
+        run = self.repository.get_run(_uid(run_uid, "run_uid"))
+        if run is None:
+            raise LookupError("quality run was not found")
+        return {
+            **run,
+            "metrics": self.repository.list_metrics(run["uid"]),
+            "findings": self.repository.list_findings(run["uid"]),
+            "sla_events": self.repository.list_sla_events(run["uid"]),
+        }
+
+    def list_runs(self, asset_uid: str | None = None) -> list[dict[str, Any]]:
+        normalized = _uid(asset_uid, "asset_uid") if asset_uid else None
+        return self.repository.list_runs(normalized)

+ 568 - 0
app/core/data_rules/quality_repository.py

@@ -0,0 +1,568 @@
+"""PostgreSQL repository for cross-domain quality operations."""
+
+from __future__ import annotations
+
+import json
+import uuid
+
+from sqlalchemy import text
+
+
+def _json(value):
+    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 SqlAlchemyQualityOperationsRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def create_template(self, template, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.quality_templates (
+                    uid, code, name, owner_uid, status, current_version,
+                    active_version_uid, created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :name,
+                    CAST(:owner_uid AS uuid), :status, :current_version,
+                    NULL, CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                """
+            ),
+            template,
+        )
+        self._insert_version(version)
+        return self.get_template(template["uid"])
+
+    def _insert_version(self, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.quality_template_versions (
+                    uid, template_uid, version, status, definition,
+                    content_hash, created_by, created_at, published_by,
+                    published_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:template_uid AS uuid), :version,
+                    :status, CAST(:definition AS jsonb), :content_hash,
+                    CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:published_by AS uuid),
+                    CAST(:published_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **version,
+                "definition": _json(version["definition"]),
+            },
+        )
+
+    def get_template(self, uid, *, for_update=False):
+        suffix = " FOR UPDATE" if for_update else ""
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, code, name, owner_uid, status, current_version,
+                           active_version_uid, created_by, created_at, updated_at
+                    FROM public.quality_templates
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                    + suffix
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def get_template_version(self, uid):
+        if uid is None:
+            return None
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, template_uid, version, status, definition,
+                           content_hash, created_by, created_at, published_by,
+                           published_at
+                    FROM public.quality_template_versions
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def versions_for_template(self, uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, template_uid, version, status, definition,
+                           content_hash, created_by, created_at, published_by,
+                           published_at
+                    FROM public.quality_template_versions
+                    WHERE template_uid = CAST(:uid AS uuid)
+                    ORDER BY version DESC
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def save_template_version(self, template, version):
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.quality_templates
+                SET status = :status,
+                    current_version = :current_version,
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :expected_version
+                """
+            ),
+            {
+                **template,
+                "expected_version": int(template["current_version"]) - 1,
+            },
+        )
+        if result.rowcount != 1:
+            raise RuntimeError("quality template version conflict")
+        self._insert_version(version)
+        return self.get_template(template["uid"])
+
+    def publish_template(self, template, version):
+        self.session.execute(
+            text(
+                """
+                UPDATE public.quality_template_versions
+                SET status = 'superseded'
+                WHERE template_uid = CAST(:template_uid AS uuid)
+                  AND status = 'published'
+                """
+            ),
+            {"template_uid": template["uid"]},
+        )
+        version_result = self.session.execute(
+            text(
+                """
+                UPDATE public.quality_template_versions
+                SET status = 'published',
+                    published_by = CAST(:published_by AS uuid),
+                    published_at = CAST(:published_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND status = 'draft'
+                """
+            ),
+            version,
+        )
+        if version_result.rowcount != 1:
+            raise RuntimeError("quality template version is not publishable")
+        template_result = self.session.execute(
+            text(
+                """
+                UPDATE public.quality_templates
+                SET status = 'published',
+                    active_version_uid = CAST(:active_version_uid AS uuid),
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :current_version
+                """
+            ),
+            template,
+        )
+        if template_result.rowcount != 1:
+            raise RuntimeError("quality template version conflict")
+        return self.get_template(template["uid"])
+
+    def list_templates(self):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, code, name, owner_uid, status, current_version,
+                           active_version_uid, created_by, created_at, updated_at
+                    FROM public.quality_templates
+                    ORDER BY updated_at DESC, uid DESC
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        ]
+
+    def get_asset(self, uid):
+        record = _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, source_uid, asset_key, namespace, name,
+                           asset_type, last_run_uid, snapshot,
+                           COALESCE(
+                               snapshot ->> 'business_domain_uid',
+                               namespace
+                           ) AS business_domain_uid
+                    FROM public.active_metadata_assets
+                    WHERE uid = CAST(:uid AS uuid)
+                      AND lifecycle_status = 'active'
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return record
+
+    def latest_run(self, asset_uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, template_uid, template_version_uid,
+                           template_hash, asset_uid, source_uid,
+                           business_domain_uid, batch_key, status, row_count,
+                           score, source_observed_at, previous_run_uid,
+                           comparison, profile, field_bindings, finding_count,
+                           deterministic, created_by, created_at
+                    FROM public.quality_profile_runs
+                    WHERE asset_uid = CAST(:asset_uid AS uuid)
+                    ORDER BY created_at DESC, uid DESC
+                    LIMIT 1
+                    """
+                ),
+                {"asset_uid": asset_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def list_runs(self, asset_uid=None):
+        where = (
+            "WHERE asset_uid = CAST(:asset_uid AS uuid)" if asset_uid else ""
+        )
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, template_uid, template_version_uid,
+                           template_hash, asset_uid, source_uid,
+                           business_domain_uid, batch_key, status, row_count,
+                           score, source_observed_at, previous_run_uid,
+                           comparison, profile, field_bindings, finding_count,
+                           deterministic, created_by, created_at
+                    FROM public.quality_profile_runs
+                    """
+                    + where
+                    + " ORDER BY created_at, uid"
+                ),
+                {"asset_uid": asset_uid} if asset_uid else {},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def get_run(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, template_uid, template_version_uid,
+                           template_hash, asset_uid, source_uid,
+                           business_domain_uid, batch_key, status, row_count,
+                           score, source_observed_at, previous_run_uid,
+                           comparison, profile, field_bindings, finding_count,
+                           deterministic, created_by, created_at
+                    FROM public.quality_profile_runs
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def recurrence_count(self, recurrence_key):
+        return int(
+            self.session.execute(
+                text(
+                    """
+                    SELECT COUNT(*)
+                    FROM public.quality_findings
+                    WHERE recurrence_key = :recurrence_key
+                    """
+                ),
+                {"recurrence_key": recurrence_key},
+            ).scalar()
+            or 0
+        )
+
+    def latest_sla_event(self, asset_uid, sla_type):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, asset_uid, sla_type, status, severity,
+                           actual, threshold, owner_uid, escalation_level,
+                           evidence, created_at
+                    FROM public.quality_sla_events
+                    WHERE asset_uid = CAST(:asset_uid AS uuid)
+                      AND sla_type = :sla_type
+                    ORDER BY created_at DESC, uid DESC
+                    LIMIT 1
+                    """
+                ),
+                {"asset_uid": asset_uid, "sla_type": sla_type},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def related_evidence(self, asset):
+        qualified_name = f"{asset['namespace']}.{asset['name']}"
+        changes = [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, field_name, change_type, status,
+                           created_at
+                    FROM public.active_metadata_changes
+                    WHERE asset_uid = CAST(:asset_uid AS uuid)
+                    ORDER BY created_at DESC, uid DESC
+                    LIMIT 20
+                    """
+                ),
+                {"asset_uid": asset["uid"]},
+            )
+            .mappings()
+            .all()
+        ]
+        lineage = [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, parse_status, source_asset,
+                           source_field, target_asset, target_field,
+                           relation_type, failure_reason, created_at
+                    FROM public.active_metadata_lineage
+                    WHERE source_asset = :qualified_name
+                       OR target_asset = :qualified_name
+                    ORDER BY created_at DESC, uid DESC
+                    LIMIT 20
+                    """
+                ),
+                {"qualified_name": qualified_name},
+            )
+            .mappings()
+            .all()
+        ]
+        metadata_run = _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, plan_uid, batch_key, status, attempt_count,
+                           failure_code, failure_reason, started_at, finished_at
+                    FROM public.active_metadata_runs
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": asset["last_run_uid"]},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return {
+            "asset": {
+                "uid": asset["uid"],
+                "source_uid": asset["source_uid"],
+                "asset_key": asset["asset_key"],
+            },
+            "lineage": lineage,
+            "changes": changes,
+            "runs": [metadata_run] if metadata_run else [],
+        }
+
+    def save_run(self, run, metrics, findings, events):
+        self.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,
+                    previous_run_uid, comparison, profile, field_bindings,
+                    finding_count, deterministic, created_by, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:template_uid AS uuid),
+                    CAST(:template_version_uid AS uuid), :template_hash,
+                    CAST(:asset_uid AS uuid), CAST(:source_uid AS uuid),
+                    :business_domain_uid, :batch_key, :status, :row_count,
+                    :score, CAST(:source_observed_at AS timestamptz),
+                    CAST(:previous_run_uid AS uuid), CAST(:comparison AS jsonb),
+                    CAST(:profile AS jsonb), CAST(:field_bindings AS jsonb),
+                    :finding_count, :deterministic,
+                    CAST(:created_by AS uuid), CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **run,
+                "comparison": _json(run["comparison"]),
+                "profile": _json(run["profile"]),
+                "field_bindings": _json(run["field_bindings"]),
+            },
+        )
+        for metric in metrics:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.quality_profile_metrics (
+                        uid, run_uid, field_name, metric_value, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:run_uid AS uuid),
+                        :field_name, CAST(:metric_value AS jsonb),
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                {
+                    "uid": metric["uid"],
+                    "run_uid": metric["run_uid"],
+                    "field_name": metric["field_name"],
+                    "metric_value": _json(metric),
+                    "created_at": metric["created_at"],
+                },
+            )
+        for finding in findings:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.quality_findings (
+                        uid, run_uid, asset_uid, finding_type, field_name,
+                        severity, status, actual, expected, recurrence_key,
+                        occurrence_number, evidence, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:run_uid AS uuid),
+                        CAST(:asset_uid AS uuid), :finding_type, :field_name,
+                        :severity, :status, CAST(:actual AS jsonb),
+                        CAST(:expected AS jsonb), :recurrence_key,
+                        :occurrence_number, CAST(:evidence AS jsonb),
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                {
+                    **finding,
+                    "actual": _json(finding["actual"]),
+                    "expected": _json(finding["expected"]),
+                    "evidence": _json(finding["evidence"]),
+                },
+            )
+        for event in events:
+            self.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), :sla_type, :status, :severity,
+                        :actual, :threshold, CAST(:owner_uid AS uuid),
+                        :escalation_level, CAST(:evidence AS jsonb),
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                {**event, "evidence": _json(event["evidence"])},
+            )
+        return self.get_run(run["uid"])
+
+    def list_metrics(self, run_uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, field_name, metric_value, created_at
+                    FROM public.quality_profile_metrics
+                    WHERE run_uid = CAST(:run_uid AS uuid)
+                    ORDER BY field_name, uid
+                    """
+                ),
+                {"run_uid": run_uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_findings(self, run_uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, asset_uid, finding_type, field_name,
+                           severity, status, actual, expected, recurrence_key,
+                           occurrence_number, evidence, created_at
+                    FROM public.quality_findings
+                    WHERE run_uid = CAST(:run_uid AS uuid)
+                    ORDER BY severity DESC, finding_type, field_name, uid
+                    """
+                ),
+                {"run_uid": run_uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_sla_events(self, run_uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, asset_uid, sla_type, status, severity,
+                           actual, threshold, owner_uid, escalation_level,
+                           evidence, created_at
+                    FROM public.quality_sla_events
+                    WHERE run_uid = CAST(:run_uid AS uuid)
+                    ORDER BY sla_type, uid
+                    """
+                ),
+                {"run_uid": run_uid},
+            )
+            .mappings()
+            .all()
+        ]

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

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

+ 140 - 0
deployment/app/api/data_rules/quality_routes.py

@@ -0,0 +1,140 @@
+"""HTTP boundary for generic, deterministic quality operations."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.data_rules import bp
+from app.models.result import failed, success
+
+
+def get_quality_operations_service():
+    from app.core.data_rules.quality_operations import QualityOperationsService
+    from app.core.data_rules.quality_repository import (
+        SqlAlchemyQualityOperationsRepository,
+    )
+
+    return QualityOperationsService(
+        SqlAlchemyQualityOperationsRepository(db.session),
+        publish_authorizer=lambda _actor_uid: None,
+        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("/quality-operations/templates")
+def list_quality_templates():
+    try:
+        return jsonify(
+            success(get_quality_operations_service().list_templates())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/templates")
+def create_quality_template():
+    try:
+        record = get_quality_operations_service().create_template(
+            _payload(),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/templates/<template_uid>/revisions")
+def revise_quality_template(template_uid):
+    try:
+        payload = _payload()
+        record = get_quality_operations_service().revise_template(
+            template_uid,
+            payload.get("definition"),
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/templates/<template_uid>/publish")
+def publish_quality_template(template_uid):
+    try:
+        payload = _payload()
+        record = get_quality_operations_service().publish_template(
+            template_uid,
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/execute")
+def execute_quality_profile():
+    try:
+        record = get_quality_operations_service().execute(
+            _payload(),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/quality-operations/runs")
+def list_quality_runs():
+    try:
+        records = get_quality_operations_service().list_runs(
+            request.args.get("asset_uid")
+        )
+        return jsonify(success(records)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/quality-operations/runs/<run_uid>")
+def get_quality_run(run_uid):
+    try:
+        return jsonify(
+            success(get_quality_operations_service().get_run(run_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/quality-operations/assets/<asset_uid>/trend")
+def get_quality_trend(asset_uid):
+    try:
+        return jsonify(
+            success(get_quality_operations_service().trend(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)

+ 1142 - 0
deployment/app/core/data_rules/quality_operations.py

@@ -0,0 +1,1142 @@
+"""Deterministic, cross-domain data quality profiling and SLA operations."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import math
+import re
+import statistics
+import uuid
+from collections import Counter
+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
+
+MAX_RECORDS = 5_000
+MAX_FIELDS = 200
+MAX_CELLS = 200_000
+MAX_INLINE_CHARACTERS = 8_000_000
+MAX_SCALAR_CHARACTERS = 10_000
+MAX_ROLES = 30
+MAX_SAMPLE_LIMIT = 20
+ROLE_CHECKS = frozenset({"unique", "pattern", "distribution", "outlier"})
+TEMPLATE_KEYS = frozenset(
+    {"schema_version", "field_roles", "thresholds", "sample_limit"}
+)
+ROLE_KEYS = frozenset({"required", "checks"})
+THRESHOLD_KEYS = frozenset(
+    {
+        "completeness_min",
+        "uniqueness_min",
+        "pattern",
+        "volume_change_max_ratio",
+        "distribution_drift_max",
+        "freshness_max_seconds",
+        "quality_score_min",
+    }
+)
+SECRET_TOKENS = frozenset(
+    {
+        "apikey",
+        "authorization",
+        "connectionstring",
+        "credential",
+        "dsn",
+        "password",
+        "secret",
+        "token",
+    }
+)
+ROLE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
+FIELD_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]{0,199}$")
+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: frozenset[str] | set[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 copy.deepcopy(value)
+
+
+def _bounded_string(value: Any, label: str, maximum: int) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    normalized = value.strip()
+    if len(normalized) > maximum:
+        raise ValueError(f"{label} exceeds {maximum} characters")
+    return normalized
+
+
+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 _ratio(value: Any, label: str, *, maximum: float = 1.0) -> float:
+    if isinstance(value, bool):
+        raise ValueError(f"{label} must be numeric")
+    try:
+        number = float(value)
+    except (TypeError, ValueError) as error:
+        raise ValueError(f"{label} must be numeric") from error
+    if not math.isfinite(number) or number < 0 or number > maximum:
+        raise ValueError(f"{label} must be between 0 and {maximum}")
+    return number
+
+
+def _positive_number(value: Any, label: str, *, maximum: float) -> float:
+    if isinstance(value, bool):
+        raise ValueError(f"{label} must be numeric")
+    try:
+        number = float(value)
+    except (TypeError, ValueError) as error:
+        raise ValueError(f"{label} must be numeric") from error
+    if not math.isfinite(number) or number <= 0 or number > maximum:
+        raise ValueError(f"{label} must be between 0 and {maximum}")
+    return number
+
+
+def _canonical(value: Any) -> str:
+    try:
+        return json.dumps(
+            value,
+            ensure_ascii=False,
+            sort_keys=True,
+            separators=(",", ":"),
+        )
+    except (TypeError, ValueError) as error:
+        raise ValueError("quality data must be JSON serializable") from error
+
+
+def _content_hash(value: Any) -> str:
+    return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
+
+
+def _parse_datetime(value: Any, label: str) -> datetime:
+    if isinstance(value, datetime):
+        parsed = value
+    else:
+        try:
+            parsed = 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 parsed.tzinfo is None:
+        raise ValueError(f"{label} must include a timezone")
+    return parsed
+
+
+def validate_quality_template(value: Any) -> dict[str, Any]:
+    """Validate a domain-neutral quality template with semantic field roles."""
+
+    template = _closed_object(value, TEMPLATE_KEYS, "quality template")
+    if template.get("schema_version") != "1.0":
+        raise ValueError("quality template schema_version must be 1.0")
+    raw_roles = template.get("field_roles")
+    if not isinstance(raw_roles, dict) or not 1 <= len(raw_roles) <= MAX_ROLES:
+        raise ValueError("field_roles must contain between 1 and 30 roles")
+    roles = {}
+    for raw_name, raw_role in raw_roles.items():
+        name = str(raw_name)
+        if not ROLE_PATTERN.fullmatch(name):
+            raise ValueError("quality field role name is invalid")
+        role = _closed_object(raw_role, ROLE_KEYS, f"field role {name}")
+        if not isinstance(role.get("required"), bool):
+            raise ValueError(f"field role {name} required must be a boolean")
+        checks = role.get("checks")
+        if not isinstance(checks, list):
+            raise ValueError(f"field role {name} checks must be an array")
+        normalized_checks = sorted({str(item) for item in checks})
+        if not set(normalized_checks) <= ROLE_CHECKS:
+            raise ValueError(f"field role {name} contains unsupported checks")
+        roles[name] = {
+            "required": role["required"],
+            "checks": normalized_checks,
+        }
+    thresholds = _closed_object(
+        template.get("thresholds"),
+        THRESHOLD_KEYS,
+        "quality thresholds",
+    )
+    required_thresholds = THRESHOLD_KEYS
+    missing = sorted(required_thresholds - set(thresholds))
+    if missing:
+        raise ValueError(
+            f"quality thresholds are missing fields: {', '.join(missing)}"
+        )
+    thresholds["completeness_min"] = _ratio(
+        thresholds["completeness_min"], "completeness_min"
+    )
+    thresholds["uniqueness_min"] = _ratio(
+        thresholds["uniqueness_min"], "uniqueness_min"
+    )
+    thresholds["volume_change_max_ratio"] = _ratio(
+        thresholds["volume_change_max_ratio"], "volume_change_max_ratio"
+    )
+    thresholds["distribution_drift_max"] = _ratio(
+        thresholds["distribution_drift_max"], "distribution_drift_max"
+    )
+    thresholds["freshness_max_seconds"] = _positive_number(
+        thresholds["freshness_max_seconds"],
+        "freshness_max_seconds",
+        maximum=31_536_000,
+    )
+    thresholds["quality_score_min"] = _ratio(
+        thresholds["quality_score_min"],
+        "quality_score_min",
+        maximum=100,
+    )
+    pattern = _bounded_string(thresholds["pattern"], "pattern", 120)
+    if (
+        not pattern.startswith("^")
+        or not pattern.endswith("$")
+        or "(?" in pattern
+        or re.search(r"\\[1-9]", pattern)
+        or re.search(r"\([^)]*[+*{][^)]*\)[+*{]", pattern)
+        or ".*.*" in pattern
+    ):
+        raise ValueError("pattern must be anchored and cannot use advanced groups")
+    try:
+        re.compile(pattern)
+    except re.error as error:
+        raise ValueError("pattern is invalid") from error
+    thresholds["pattern"] = pattern
+    sample_limit = template.get("sample_limit")
+    if (
+        isinstance(sample_limit, bool)
+        or not isinstance(sample_limit, int)
+        or not 1 <= sample_limit <= MAX_SAMPLE_LIMIT
+    ):
+        raise ValueError("sample_limit must be between 1 and 20")
+    return {
+        "schema_version": "1.0",
+        "field_roles": roles,
+        "thresholds": thresholds,
+        "sample_limit": sample_limit,
+    }
+
+
+def _scalar(value: Any) -> bool:
+    return value is None or isinstance(value, (str, int, float, bool))
+
+
+def _normalized_records(value: Any) -> list[dict[str, Any]]:
+    if not isinstance(value, list) or not 1 <= len(value) <= MAX_RECORDS:
+        raise ValueError("records must contain between 1 and 5000 rows")
+    if sum(len(item) for item in value if isinstance(item, dict)) > MAX_CELLS:
+        raise ValueError("quality records contain too many cells")
+    field_names = set()
+    records = []
+    inline_characters = 0
+    for raw in value:
+        if not isinstance(raw, dict):
+            raise ValueError("each quality record must be an object")
+        if len(raw) > MAX_FIELDS:
+            raise ValueError("a quality record contains too many fields")
+        record = {}
+        for raw_field, item in raw.items():
+            field = str(raw_field)
+            if not FIELD_PATTERN.fullmatch(field):
+                raise ValueError("quality record field name is invalid")
+            if not _scalar(item):
+                raise ValueError("quality record values must be scalar")
+            if isinstance(item, float) and not math.isfinite(item):
+                raise ValueError("quality record numeric values must be finite")
+            if (
+                isinstance(item, int)
+                and not isinstance(item, bool)
+                and item.bit_length() > 256
+            ):
+                raise ValueError("quality record integer value is too large")
+            if isinstance(item, str):
+                if len(item) > MAX_SCALAR_CHARACTERS:
+                    raise ValueError("quality record scalar value is too large")
+                inline_characters += len(item)
+                if inline_characters > MAX_INLINE_CHARACTERS:
+                    raise ValueError("quality records inline content is too large")
+            record[field] = item
+            field_names.add(field)
+        records.append(record)
+    if len(field_names) > MAX_FIELDS:
+        raise ValueError("quality records contain too many fields")
+    return records
+
+
+def _value_token(value: Any) -> str:
+    digest = hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()[:16]
+    return f"sha256:{digest}"
+
+
+def _type_name(value: Any) -> str:
+    if value is None:
+        return "null"
+    if isinstance(value, bool):
+        return "boolean"
+    if isinstance(value, int):
+        return "integer"
+    if isinstance(value, float):
+        return "number"
+    return "string"
+
+
+def _field_profile(
+    records: list[dict[str, Any]],
+    field: str,
+    *,
+    sample_limit: int,
+) -> dict[str, Any]:
+    values = [item.get(field) for item in records]
+    populated = [item for item in values if item is not None and item != ""]
+    counts = Counter(_canonical(item) for item in populated)
+    originals = {}
+    for item in populated:
+        originals.setdefault(_canonical(item), item)
+    top_values = [
+        {"value_token": _value_token(originals[key]), "count": count}
+        for key, count in sorted(
+            counts.items(), key=lambda item: (-item[1], item[0])
+        )[:sample_limit]
+    ]
+    type_counts = Counter(_type_name(item) for item in values)
+    return {
+        "field_name": field,
+        "row_count": len(records),
+        "null_count": len(values) - len(populated),
+        "null_rate": round(
+            (len(values) - len(populated)) / len(records),
+            6,
+        ),
+        "completeness_rate": round(len(populated) / len(records), 6),
+        "distinct_count": len(counts),
+        "uniqueness_rate": round(
+            len(counts) / len(populated), 6
+        )
+        if populated
+        else 0.0,
+        "type_counts": dict(sorted(type_counts.items())),
+        "top_values": top_values,
+        "sample_tokens": sorted({_value_token(item) for item in populated})[
+            :sample_limit
+        ],
+    }
+
+
+def _distribution(profile: dict[str, Any]) -> dict[str, float]:
+    total = sum(item["count"] for item in profile["top_values"])
+    if total <= 0:
+        return {}
+    return {
+        item["value_token"]: item["count"] / total
+        for item in profile["top_values"]
+    }
+
+
+def _distribution_distance(
+    current: dict[str, float],
+    previous: dict[str, float],
+) -> float:
+    keys = set(current) | set(previous)
+    return round(
+        0.5
+        * sum(abs(current.get(key, 0.0) - previous.get(key, 0.0)) for key in keys),
+        6,
+    )
+
+
+def _schema_signature(profiles: dict[str, dict[str, Any]]) -> dict[str, Any]:
+    return {
+        field: {
+            "types": sorted(
+                key for key in profile["type_counts"] if key != "null"
+            ),
+        }
+        for field, profile in sorted(profiles.items())
+    }
+
+
+def _outlier_tokens(values: list[Any], sample_limit: int) -> list[str]:
+    numeric = [
+        float(item)
+        for item in values
+        if isinstance(item, (int, float)) and not isinstance(item, bool)
+    ]
+    if len(numeric) < 4:
+        return []
+    median = statistics.median(numeric)
+    deviations = [abs(item - median) for item in numeric]
+    mad = statistics.median(deviations)
+    if mad == 0:
+        outliers = [item for item in numeric if item != median]
+    else:
+        outliers = [
+            item
+            for item in numeric
+            if 0.6745 * abs(item - median) / mad > 3.5
+        ]
+    return sorted({_value_token(item) for item in outliers})[:sample_limit]
+
+
+class QualityOperationsService:
+    """Govern quality templates and persist deterministic operational evidence."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        publish_authorizer: Callable[[str], None],
+        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.publish_authorizer = publish_authorizer
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def create_template(
+        self,
+        payload: Any,
+        *,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed_object(
+            payload,
+            {"code", "name", "owner_uid", "definition"},
+            "quality template request",
+        )
+        code = _bounded_string(body.get("code"), "code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("quality template code is invalid")
+        name = _bounded_string(body.get("name"), "name", 300)
+        owner_uid = _uid(body.get("owner_uid"), "owner_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        definition = validate_quality_template(body.get("definition"))
+        now = self.now_factory()
+        template_uid = self.uid_factory()
+        version_uid = self.uid_factory()
+        template = {
+            "uid": template_uid,
+            "code": code,
+            "name": name,
+            "owner_uid": owner_uid,
+            "status": "draft",
+            "current_version": 1,
+            "active_version_uid": None,
+            "created_by": actor,
+            "created_at": now.isoformat(),
+            "updated_at": now.isoformat(),
+        }
+        version = {
+            "uid": version_uid,
+            "template_uid": template_uid,
+            "version": 1,
+            "status": "draft",
+            "definition": definition,
+            "content_hash": _content_hash(definition),
+            "created_by": actor,
+            "created_at": now.isoformat(),
+            "published_by": None,
+            "published_at": None,
+        }
+        try:
+            self.repository.create_template(template, version)
+            self.commit()
+            return {**template, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def revise_template(
+        self,
+        template_uid: str,
+        definition: Any,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(template_uid, "template_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        template = self.repository.get_template(uid, for_update=True)
+        if template is None:
+            raise LookupError("quality template was not found")
+        if int(template["current_version"]) != int(expected_version):
+            raise RuntimeError("quality template version conflict")
+        normalized = validate_quality_template(definition)
+        version_number = int(template["current_version"]) + 1
+        now = self.now_factory()
+        version = {
+            "uid": self.uid_factory(),
+            "template_uid": uid,
+            "version": version_number,
+            "status": "draft",
+            "definition": normalized,
+            "content_hash": _content_hash(normalized),
+            "created_by": actor,
+            "created_at": now.isoformat(),
+            "published_by": None,
+            "published_at": None,
+        }
+        updated = {
+            **template,
+            "status": "draft",
+            "current_version": version_number,
+            "updated_at": now.isoformat(),
+        }
+        try:
+            self.repository.save_template_version(updated, version)
+            self.commit()
+            return {**updated, "latest_version": version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def publish_template(
+        self,
+        template_uid: str,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        uid = _uid(template_uid, "template_uid")
+        actor = _uid(actor_uid, "actor_uid")
+        self.publish_authorizer(actor)
+        template = self.repository.get_template(uid, for_update=True)
+        if template is None:
+            raise LookupError("quality template was not found")
+        if int(template["current_version"]) != int(expected_version):
+            raise RuntimeError("quality template version conflict")
+        version = next(
+            (
+                item
+                for item in self.repository.versions_for_template(uid)
+                if int(item["version"]) == int(expected_version)
+            ),
+            None,
+        )
+        if version is None:
+            raise LookupError("quality template version was not found")
+        now = self.now_factory()
+        published_version = {
+            **version,
+            "status": "published",
+            "published_by": actor,
+            "published_at": now.isoformat(),
+        }
+        published = {
+            **template,
+            "status": "published",
+            "active_version_uid": version["uid"],
+            "updated_at": now.isoformat(),
+        }
+        try:
+            self.repository.publish_template(published, published_version)
+            self.commit()
+            return {**published, "active_version": published_version}
+        except Exception:
+            self.rollback()
+            raise
+
+    def list_templates(self) -> list[dict[str, Any]]:
+        records = []
+        for template in self.repository.list_templates():
+            versions = self.repository.versions_for_template(template["uid"])
+            records.append(
+                {
+                    **template,
+                    "latest_version": versions[0] if versions else None,
+                    "active_version": next(
+                        (
+                            item
+                            for item in versions
+                            if item["uid"] == template.get("active_version_uid")
+                        ),
+                        None,
+                    ),
+                }
+            )
+        return records
+
+    def _binding(
+        self,
+        definition: dict[str, Any],
+        value: Any,
+    ) -> dict[str, str]:
+        if not isinstance(value, dict):
+            raise ValueError("field_bindings must be an object")
+        if set(value) != set(definition["field_roles"]):
+            raise ValueError("field_bindings must match template field roles")
+        result = {}
+        for role, field in value.items():
+            if not FIELD_PATTERN.fullmatch(str(field)):
+                raise ValueError("field binding is invalid")
+            result[str(role)] = str(field)
+        if len(set(result.values())) != len(result):
+            raise ValueError("field bindings must be unique")
+        return result
+
+    def execute(
+        self,
+        payload: Any,
+        *,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        body = _closed_object(
+            payload,
+            {
+                "template_uid",
+                "asset_uid",
+                "batch_key",
+                "field_bindings",
+                "source_observed_at",
+                "records",
+            },
+            "quality execution request",
+        )
+        actor = _uid(actor_uid, "actor_uid")
+        template_uid = _uid(body.get("template_uid"), "template_uid")
+        asset_uid = _uid(body.get("asset_uid"), "asset_uid")
+        batch_key = _bounded_string(body.get("batch_key"), "batch_key", 160)
+        for existing in self.repository.list_runs(asset_uid):
+            if existing["batch_key"] == batch_key:
+                return existing
+        template = self.repository.get_template(template_uid)
+        if template is None or template.get("status") != "published":
+            raise RuntimeError("quality template must be published before execution")
+        version = self.repository.get_template_version(
+            template.get("active_version_uid")
+        )
+        if version is None or version.get("status") != "published":
+            raise RuntimeError("published quality template version was not found")
+        definition = validate_quality_template(version["definition"])
+        bindings = self._binding(definition, body.get("field_bindings"))
+        records = _normalized_records(body.get("records"))
+        bound_fields = set(bindings.values())
+        missing_fields = sorted(
+            field for field in bound_fields if all(field not in row for row in records)
+        )
+        if missing_fields:
+            raise ValueError(
+                f"bound fields are absent from records: {', '.join(missing_fields)}"
+            )
+        asset = self.repository.get_asset(asset_uid)
+        if asset is None:
+            raise LookupError("active metadata asset was not found")
+        source_observed_at = _parse_datetime(
+            body.get("source_observed_at"),
+            "source_observed_at",
+        )
+        now = self.now_factory()
+        if source_observed_at > now:
+            raise ValueError("source_observed_at cannot be in the future")
+        profiles = {
+            field: _field_profile(
+                records,
+                field,
+                sample_limit=definition["sample_limit"],
+            )
+            for field in sorted({key for row in records for key in row})
+        }
+        compiled_pattern = re.compile(definition["thresholds"]["pattern"])
+        for role, role_spec in definition["field_roles"].items():
+            if "pattern" not in role_spec["checks"]:
+                continue
+            field = bindings[role]
+            values = [
+                row.get(field)
+                for row in records
+                if row.get(field) not in (None, "")
+            ]
+            mismatches = [
+                item
+                for item in values
+                if not compiled_pattern.fullmatch(str(item))
+            ]
+            profiles[field]["pattern"] = {
+                "evaluated_count": len(values),
+                "mismatch_count": len(mismatches),
+                "match_rate": (
+                    round((len(values) - len(mismatches)) / len(values), 6)
+                    if values
+                    else 0.0
+                ),
+                "mismatch_sample_tokens": sorted(
+                    {_value_token(item) for item in mismatches}
+                )[: definition["sample_limit"]],
+            }
+        previous = self.repository.latest_run(asset_uid)
+        root_cause = self.repository.related_evidence(asset)
+        root_cause["responsibility"] = {
+            "owner_uid": template["owner_uid"],
+            "source": "quality_template",
+        }
+        run_uid = self.uid_factory()
+        findings = self._findings(
+            run_uid=run_uid,
+            template=template,
+            definition=definition,
+            bindings=bindings,
+            records=records,
+            profiles=profiles,
+            previous=previous,
+            source_observed_at=source_observed_at,
+            now=now,
+            root_cause=root_cause,
+        )
+        score = round(max(0.0, 100.0 - len(findings) * 12.0), 2)
+        comparison = self._comparison(
+            row_count=len(records),
+            score=score,
+            previous=previous,
+        )
+        run = {
+            "uid": run_uid,
+            "template_uid": template_uid,
+            "template_version_uid": version["uid"],
+            "template_hash": version["content_hash"],
+            "asset_uid": asset_uid,
+            "source_uid": asset["source_uid"],
+            "business_domain_uid": asset.get("business_domain_uid"),
+            "batch_key": batch_key,
+            "status": "success",
+            "row_count": len(records),
+            "score": score,
+            "source_observed_at": source_observed_at.isoformat(),
+            "previous_run_uid": previous["uid"] if previous else None,
+            "comparison": comparison,
+            "profile": {
+                "fields": profiles,
+                "schema": _schema_signature(profiles),
+            },
+            "field_bindings": bindings,
+            "finding_count": len(findings),
+            "deterministic": True,
+            "created_by": actor,
+            "created_at": now.isoformat(),
+        }
+        events = self._sla_events(run, definition, findings)
+        metrics = [
+            {
+                "uid": self.uid_factory(),
+                "run_uid": run_uid,
+                "created_at": now.isoformat(),
+                **profile,
+            }
+            for profile in profiles.values()
+        ]
+        try:
+            self.repository.save_run(
+                run,
+                metrics,
+                findings,
+                events,
+            )
+            self.commit()
+            return run
+        except Exception:
+            self.rollback()
+            raise
+
+    def _finding(
+        self,
+        *,
+        run_uid: str,
+        template_uid: str,
+        asset_uid: str,
+        finding_type: str,
+        field_name: str | None,
+        severity: str,
+        actual: Any,
+        expected: Any,
+        root_cause: dict[str, Any],
+    ) -> dict[str, Any]:
+        recurrence_key = _content_hash(
+            {
+                "template_uid": template_uid,
+                "asset_uid": asset_uid,
+                "finding_type": finding_type,
+                "field_name": field_name,
+            }
+        )
+        occurrence = self.repository.recurrence_count(recurrence_key) + 1
+        return {
+            "uid": self.uid_factory(),
+            "run_uid": run_uid,
+            "asset_uid": asset_uid,
+            "finding_type": finding_type,
+            "field_name": field_name,
+            "severity": severity,
+            "status": "open",
+            "actual": copy.deepcopy(actual),
+            "expected": copy.deepcopy(expected),
+            "recurrence_key": recurrence_key,
+            "occurrence_number": occurrence,
+            "evidence": {
+                "deterministic": True,
+                "root_cause": copy.deepcopy(root_cause),
+            },
+            "created_at": self.now_factory().isoformat(),
+        }
+
+    def _findings(
+        self,
+        *,
+        run_uid,
+        template,
+        definition,
+        bindings,
+        records,
+        profiles,
+        previous,
+        source_observed_at,
+        now,
+        root_cause,
+    ):
+        thresholds = definition["thresholds"]
+        findings = []
+
+        def add(kind, field, actual, expected, severity="error"):
+            findings.append(
+                self._finding(
+                    run_uid=run_uid,
+                    template_uid=template["uid"],
+                    asset_uid=root_cause["asset"]["uid"],
+                    finding_type=kind,
+                    field_name=field,
+                    severity=severity,
+                    actual=actual,
+                    expected=expected,
+                    root_cause=root_cause,
+                )
+            )
+
+        for role, role_spec in definition["field_roles"].items():
+            field = bindings[role]
+            profile = profiles[field]
+            if (
+                role_spec["required"]
+                and profile["completeness_rate"]
+                < thresholds["completeness_min"]
+            ):
+                add(
+                    "completeness",
+                    field,
+                    profile["completeness_rate"],
+                    {"minimum": thresholds["completeness_min"]},
+                )
+            if (
+                "unique" in role_spec["checks"]
+                and profile["uniqueness_rate"] < thresholds["uniqueness_min"]
+            ):
+                add(
+                    "uniqueness",
+                    field,
+                    profile["uniqueness_rate"],
+                    {"minimum": thresholds["uniqueness_min"]},
+                )
+                duplicate_count = sum(
+                    count - 1
+                    for count in Counter(
+                        _canonical(row.get(field))
+                        for row in records
+                        if row.get(field) not in (None, "")
+                    ).values()
+                    if count > 1
+                )
+                if duplicate_count:
+                    add(
+                        "duplicate",
+                        field,
+                        {"duplicate_row_count": duplicate_count},
+                        {"duplicate_row_count": 0},
+                    )
+            if "pattern" in role_spec["checks"]:
+                pattern_profile = profile["pattern"]
+                if pattern_profile["mismatch_count"]:
+                    add(
+                        "pattern",
+                        field,
+                        {
+                            "mismatch_count": pattern_profile["mismatch_count"],
+                            "match_rate": pattern_profile["match_rate"],
+                            "sample_tokens": pattern_profile[
+                                "mismatch_sample_tokens"
+                            ],
+                        },
+                        {"pattern": thresholds["pattern"]},
+                    )
+            if "outlier" in role_spec["checks"]:
+                tokens = _outlier_tokens(
+                    [row.get(field) for row in records],
+                    definition["sample_limit"],
+                )
+                if tokens:
+                    add(
+                        "outlier",
+                        field,
+                        {"sample_tokens": tokens},
+                        {"outlier_count": 0},
+                        severity="warning",
+                    )
+        freshness_seconds = max(0.0, (now - source_observed_at).total_seconds())
+        if freshness_seconds > thresholds["freshness_max_seconds"]:
+            add(
+                "freshness",
+                None,
+                {"seconds": round(freshness_seconds, 3)},
+                {"maximum_seconds": thresholds["freshness_max_seconds"]},
+                severity="critical",
+            )
+        if previous is not None:
+            previous_count = int(previous["row_count"])
+            ratio = (
+                abs(len(records) - previous_count) / previous_count
+                if previous_count
+                else 1.0
+            )
+            if ratio > thresholds["volume_change_max_ratio"]:
+                add(
+                    "volume",
+                    None,
+                    {"row_count": len(records), "change_ratio": round(ratio, 6)},
+                    {
+                        "previous_row_count": previous_count,
+                        "maximum_change_ratio": thresholds[
+                            "volume_change_max_ratio"
+                        ],
+                    },
+                )
+            previous_profiles = previous.get("profile", {}).get("fields", {})
+            for role, role_spec in definition["field_roles"].items():
+                if "distribution" not in role_spec["checks"]:
+                    continue
+                field = bindings[role]
+                previous_profile = previous_profiles.get(field)
+                if previous_profile is None:
+                    continue
+                distance = _distribution_distance(
+                    _distribution(profiles[field]),
+                    _distribution(previous_profile),
+                )
+                if distance > thresholds["distribution_drift_max"]:
+                    add(
+                        "distribution",
+                        field,
+                        {"distance": distance},
+                        {
+                            "maximum_distance": thresholds[
+                                "distribution_drift_max"
+                            ]
+                        },
+                        severity="warning",
+                    )
+            previous_schema = previous.get("profile", {}).get("schema", {})
+            current_schema = _schema_signature(profiles)
+            if current_schema != previous_schema:
+                add(
+                    "schema",
+                    None,
+                    {
+                        "current_hash": _content_hash(current_schema),
+                        "changed": True,
+                    },
+                    {"previous_hash": _content_hash(previous_schema)},
+                )
+        return findings
+
+    @staticmethod
+    def _comparison(*, row_count, score, previous):
+        if previous is None:
+            return {
+                "row_count_change_ratio": None,
+                "score_delta": None,
+                "period": "baseline",
+            }
+        previous_count = int(previous["row_count"])
+        change = (
+            (row_count - previous_count) / previous_count
+            if previous_count
+            else 1.0
+        )
+        return {
+            "row_count_change_ratio": round(change, 6),
+            "score_delta": round(score - float(previous["score"]), 2),
+            "period": "previous_run",
+        }
+
+    def _sla_events(self, run, definition, findings):
+        thresholds = definition["thresholds"]
+        freshness_finding = next(
+            (item for item in findings if item["finding_type"] == "freshness"),
+            None,
+        )
+        checks = [
+            (
+                "freshness",
+                freshness_finding is None,
+                (
+                    0
+                    if freshness_finding is None
+                    else freshness_finding["actual"]["seconds"]
+                ),
+                thresholds["freshness_max_seconds"],
+            ),
+            (
+                "quality_score",
+                run["score"] >= thresholds["quality_score_min"],
+                run["score"],
+                thresholds["quality_score_min"],
+            ),
+        ]
+        events = []
+        for sla_type, met, actual, threshold in checks:
+            previous = self.repository.latest_sla_event(
+                run["asset_uid"], sla_type
+            )
+            status = "met" if met else "violated"
+            if met and previous and previous["status"] == "violated":
+                status = "recovered"
+            recurrence = max(
+                (
+                    item["occurrence_number"]
+                    for item in findings
+                    if (
+                        sla_type == "quality_score"
+                        or item["finding_type"] == "freshness"
+                    )
+                ),
+                default=0,
+            )
+            events.append(
+                {
+                    "uid": self.uid_factory(),
+                    "run_uid": run["uid"],
+                    "asset_uid": run["asset_uid"],
+                    "sla_type": sla_type,
+                    "status": status,
+                    "severity": "critical" if not met else "info",
+                    "actual": actual,
+                    "threshold": threshold,
+                    "owner_uid": (
+                        self.repository.get_template(run["template_uid"])[
+                            "owner_uid"
+                        ]
+                    ),
+                    "escalation_level": (
+                        min(3, max(1, recurrence)) if not met else 0
+                    ),
+                    "evidence": {
+                        "run_uid": run["uid"],
+                        "deterministic": True,
+                    },
+                    "created_at": run["created_at"],
+                }
+            )
+        return events
+
+    def trend(self, asset_uid: str) -> dict[str, Any]:
+        uid = _uid(asset_uid, "asset_uid")
+        runs = sorted(
+            self.repository.list_runs(uid),
+            key=lambda item: (item["created_at"], item["uid"]),
+        )
+        points = [
+            {
+                "run_uid": item["uid"],
+                "created_at": item["created_at"],
+                "row_count": item["row_count"],
+                "score": item["score"],
+                "finding_count": item["finding_count"],
+            }
+            for item in runs
+        ]
+        latest = (
+            {
+                **runs[-1]["comparison"],
+                "run_uid": runs[-1]["uid"],
+            }
+            if runs
+            else None
+        )
+        return {
+            "asset_uid": uid,
+            "points": points,
+            "latest": latest,
+            "year_over_year": self._period_delta(runs, days=365),
+            "month_over_month": self._period_delta(runs, days=30),
+        }
+
+    @staticmethod
+    def _period_delta(runs: list[dict[str, Any]], *, days: int):
+        if len(runs) < 2:
+            return None
+        latest = runs[-1]
+        latest_at = _parse_datetime(latest["created_at"], "created_at")
+        candidates = [
+            item
+            for item in runs[:-1]
+            if (
+                latest_at - _parse_datetime(item["created_at"], "created_at")
+            ).days
+            >= days
+        ]
+        if not candidates:
+            return None
+        baseline = candidates[-1]
+        return {
+            "baseline_run_uid": baseline["uid"],
+            "score_delta": round(
+                float(latest["score"]) - float(baseline["score"]), 2
+            ),
+            "row_count_delta": int(latest["row_count"])
+            - int(baseline["row_count"]),
+        }
+
+    def get_run(self, run_uid: str) -> dict[str, Any]:
+        run = self.repository.get_run(_uid(run_uid, "run_uid"))
+        if run is None:
+            raise LookupError("quality run was not found")
+        return {
+            **run,
+            "metrics": self.repository.list_metrics(run["uid"]),
+            "findings": self.repository.list_findings(run["uid"]),
+            "sla_events": self.repository.list_sla_events(run["uid"]),
+        }
+
+    def list_runs(self, asset_uid: str | None = None) -> list[dict[str, Any]]:
+        normalized = _uid(asset_uid, "asset_uid") if asset_uid else None
+        return self.repository.list_runs(normalized)

+ 568 - 0
deployment/app/core/data_rules/quality_repository.py

@@ -0,0 +1,568 @@
+"""PostgreSQL repository for cross-domain quality operations."""
+
+from __future__ import annotations
+
+import json
+import uuid
+
+from sqlalchemy import text
+
+
+def _json(value):
+    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 SqlAlchemyQualityOperationsRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def create_template(self, template, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.quality_templates (
+                    uid, code, name, owner_uid, status, current_version,
+                    active_version_uid, created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :code, :name,
+                    CAST(:owner_uid AS uuid), :status, :current_version,
+                    NULL, CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                """
+            ),
+            template,
+        )
+        self._insert_version(version)
+        return self.get_template(template["uid"])
+
+    def _insert_version(self, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.quality_template_versions (
+                    uid, template_uid, version, status, definition,
+                    content_hash, created_by, created_at, published_by,
+                    published_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:template_uid AS uuid), :version,
+                    :status, CAST(:definition AS jsonb), :content_hash,
+                    CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:published_by AS uuid),
+                    CAST(:published_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **version,
+                "definition": _json(version["definition"]),
+            },
+        )
+
+    def get_template(self, uid, *, for_update=False):
+        suffix = " FOR UPDATE" if for_update else ""
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, code, name, owner_uid, status, current_version,
+                           active_version_uid, created_by, created_at, updated_at
+                    FROM public.quality_templates
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                    + suffix
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def get_template_version(self, uid):
+        if uid is None:
+            return None
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, template_uid, version, status, definition,
+                           content_hash, created_by, created_at, published_by,
+                           published_at
+                    FROM public.quality_template_versions
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def versions_for_template(self, uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, template_uid, version, status, definition,
+                           content_hash, created_by, created_at, published_by,
+                           published_at
+                    FROM public.quality_template_versions
+                    WHERE template_uid = CAST(:uid AS uuid)
+                    ORDER BY version DESC
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def save_template_version(self, template, version):
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.quality_templates
+                SET status = :status,
+                    current_version = :current_version,
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :expected_version
+                """
+            ),
+            {
+                **template,
+                "expected_version": int(template["current_version"]) - 1,
+            },
+        )
+        if result.rowcount != 1:
+            raise RuntimeError("quality template version conflict")
+        self._insert_version(version)
+        return self.get_template(template["uid"])
+
+    def publish_template(self, template, version):
+        self.session.execute(
+            text(
+                """
+                UPDATE public.quality_template_versions
+                SET status = 'superseded'
+                WHERE template_uid = CAST(:template_uid AS uuid)
+                  AND status = 'published'
+                """
+            ),
+            {"template_uid": template["uid"]},
+        )
+        version_result = self.session.execute(
+            text(
+                """
+                UPDATE public.quality_template_versions
+                SET status = 'published',
+                    published_by = CAST(:published_by AS uuid),
+                    published_at = CAST(:published_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND status = 'draft'
+                """
+            ),
+            version,
+        )
+        if version_result.rowcount != 1:
+            raise RuntimeError("quality template version is not publishable")
+        template_result = self.session.execute(
+            text(
+                """
+                UPDATE public.quality_templates
+                SET status = 'published',
+                    active_version_uid = CAST(:active_version_uid AS uuid),
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :current_version
+                """
+            ),
+            template,
+        )
+        if template_result.rowcount != 1:
+            raise RuntimeError("quality template version conflict")
+        return self.get_template(template["uid"])
+
+    def list_templates(self):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, code, name, owner_uid, status, current_version,
+                           active_version_uid, created_by, created_at, updated_at
+                    FROM public.quality_templates
+                    ORDER BY updated_at DESC, uid DESC
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        ]
+
+    def get_asset(self, uid):
+        record = _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, source_uid, asset_key, namespace, name,
+                           asset_type, last_run_uid, snapshot,
+                           COALESCE(
+                               snapshot ->> 'business_domain_uid',
+                               namespace
+                           ) AS business_domain_uid
+                    FROM public.active_metadata_assets
+                    WHERE uid = CAST(:uid AS uuid)
+                      AND lifecycle_status = 'active'
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return record
+
+    def latest_run(self, asset_uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, template_uid, template_version_uid,
+                           template_hash, asset_uid, source_uid,
+                           business_domain_uid, batch_key, status, row_count,
+                           score, source_observed_at, previous_run_uid,
+                           comparison, profile, field_bindings, finding_count,
+                           deterministic, created_by, created_at
+                    FROM public.quality_profile_runs
+                    WHERE asset_uid = CAST(:asset_uid AS uuid)
+                    ORDER BY created_at DESC, uid DESC
+                    LIMIT 1
+                    """
+                ),
+                {"asset_uid": asset_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def list_runs(self, asset_uid=None):
+        where = (
+            "WHERE asset_uid = CAST(:asset_uid AS uuid)" if asset_uid else ""
+        )
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, template_uid, template_version_uid,
+                           template_hash, asset_uid, source_uid,
+                           business_domain_uid, batch_key, status, row_count,
+                           score, source_observed_at, previous_run_uid,
+                           comparison, profile, field_bindings, finding_count,
+                           deterministic, created_by, created_at
+                    FROM public.quality_profile_runs
+                    """
+                    + where
+                    + " ORDER BY created_at, uid"
+                ),
+                {"asset_uid": asset_uid} if asset_uid else {},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def get_run(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, template_uid, template_version_uid,
+                           template_hash, asset_uid, source_uid,
+                           business_domain_uid, batch_key, status, row_count,
+                           score, source_observed_at, previous_run_uid,
+                           comparison, profile, field_bindings, finding_count,
+                           deterministic, created_by, created_at
+                    FROM public.quality_profile_runs
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def recurrence_count(self, recurrence_key):
+        return int(
+            self.session.execute(
+                text(
+                    """
+                    SELECT COUNT(*)
+                    FROM public.quality_findings
+                    WHERE recurrence_key = :recurrence_key
+                    """
+                ),
+                {"recurrence_key": recurrence_key},
+            ).scalar()
+            or 0
+        )
+
+    def latest_sla_event(self, asset_uid, sla_type):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, asset_uid, sla_type, status, severity,
+                           actual, threshold, owner_uid, escalation_level,
+                           evidence, created_at
+                    FROM public.quality_sla_events
+                    WHERE asset_uid = CAST(:asset_uid AS uuid)
+                      AND sla_type = :sla_type
+                    ORDER BY created_at DESC, uid DESC
+                    LIMIT 1
+                    """
+                ),
+                {"asset_uid": asset_uid, "sla_type": sla_type},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def related_evidence(self, asset):
+        qualified_name = f"{asset['namespace']}.{asset['name']}"
+        changes = [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, field_name, change_type, status,
+                           created_at
+                    FROM public.active_metadata_changes
+                    WHERE asset_uid = CAST(:asset_uid AS uuid)
+                    ORDER BY created_at DESC, uid DESC
+                    LIMIT 20
+                    """
+                ),
+                {"asset_uid": asset["uid"]},
+            )
+            .mappings()
+            .all()
+        ]
+        lineage = [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, parse_status, source_asset,
+                           source_field, target_asset, target_field,
+                           relation_type, failure_reason, created_at
+                    FROM public.active_metadata_lineage
+                    WHERE source_asset = :qualified_name
+                       OR target_asset = :qualified_name
+                    ORDER BY created_at DESC, uid DESC
+                    LIMIT 20
+                    """
+                ),
+                {"qualified_name": qualified_name},
+            )
+            .mappings()
+            .all()
+        ]
+        metadata_run = _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, plan_uid, batch_key, status, attempt_count,
+                           failure_code, failure_reason, started_at, finished_at
+                    FROM public.active_metadata_runs
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": asset["last_run_uid"]},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return {
+            "asset": {
+                "uid": asset["uid"],
+                "source_uid": asset["source_uid"],
+                "asset_key": asset["asset_key"],
+            },
+            "lineage": lineage,
+            "changes": changes,
+            "runs": [metadata_run] if metadata_run else [],
+        }
+
+    def save_run(self, run, metrics, findings, events):
+        self.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,
+                    previous_run_uid, comparison, profile, field_bindings,
+                    finding_count, deterministic, created_by, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:template_uid AS uuid),
+                    CAST(:template_version_uid AS uuid), :template_hash,
+                    CAST(:asset_uid AS uuid), CAST(:source_uid AS uuid),
+                    :business_domain_uid, :batch_key, :status, :row_count,
+                    :score, CAST(:source_observed_at AS timestamptz),
+                    CAST(:previous_run_uid AS uuid), CAST(:comparison AS jsonb),
+                    CAST(:profile AS jsonb), CAST(:field_bindings AS jsonb),
+                    :finding_count, :deterministic,
+                    CAST(:created_by AS uuid), CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **run,
+                "comparison": _json(run["comparison"]),
+                "profile": _json(run["profile"]),
+                "field_bindings": _json(run["field_bindings"]),
+            },
+        )
+        for metric in metrics:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.quality_profile_metrics (
+                        uid, run_uid, field_name, metric_value, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:run_uid AS uuid),
+                        :field_name, CAST(:metric_value AS jsonb),
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                {
+                    "uid": metric["uid"],
+                    "run_uid": metric["run_uid"],
+                    "field_name": metric["field_name"],
+                    "metric_value": _json(metric),
+                    "created_at": metric["created_at"],
+                },
+            )
+        for finding in findings:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.quality_findings (
+                        uid, run_uid, asset_uid, finding_type, field_name,
+                        severity, status, actual, expected, recurrence_key,
+                        occurrence_number, evidence, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:run_uid AS uuid),
+                        CAST(:asset_uid AS uuid), :finding_type, :field_name,
+                        :severity, :status, CAST(:actual AS jsonb),
+                        CAST(:expected AS jsonb), :recurrence_key,
+                        :occurrence_number, CAST(:evidence AS jsonb),
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                {
+                    **finding,
+                    "actual": _json(finding["actual"]),
+                    "expected": _json(finding["expected"]),
+                    "evidence": _json(finding["evidence"]),
+                },
+            )
+        for event in events:
+            self.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), :sla_type, :status, :severity,
+                        :actual, :threshold, CAST(:owner_uid AS uuid),
+                        :escalation_level, CAST(:evidence AS jsonb),
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                {**event, "evidence": _json(event["evidence"])},
+            )
+        return self.get_run(run["uid"])
+
+    def list_metrics(self, run_uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, field_name, metric_value, created_at
+                    FROM public.quality_profile_metrics
+                    WHERE run_uid = CAST(:run_uid AS uuid)
+                    ORDER BY field_name, uid
+                    """
+                ),
+                {"run_uid": run_uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_findings(self, run_uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, asset_uid, finding_type, field_name,
+                           severity, status, actual, expected, recurrence_key,
+                           occurrence_number, evidence, created_at
+                    FROM public.quality_findings
+                    WHERE run_uid = CAST(:run_uid AS uuid)
+                    ORDER BY severity DESC, finding_type, field_name, uid
+                    """
+                ),
+                {"run_uid": run_uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_sla_events(self, run_uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, run_uid, asset_uid, sla_type, status, severity,
+                           actual, threshold, owner_uid, escalation_level,
+                           evidence, created_at
+                    FROM public.quality_sla_events
+                    WHERE run_uid = CAST(:run_uid AS uuid)
+                    ORDER BY sla_type, uid
+                    """
+                ),
+                {"run_uid": run_uid},
+            )
+            .mappings()
+            .all()
+        ]

+ 101 - 0
deployment/migrations/versions/20260730_370_governance_domain_templates.py

@@ -0,0 +1,101 @@
+"""Add generic governance object types and versioned domain templates."""
+
+from alembic import op
+
+revision = "20260730_370"
+down_revision = "20260730_360"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.governance_domain_templates (
+            uid UUID PRIMARY KEY,
+            template_code VARCHAR(64) NOT NULL UNIQUE,
+            name VARCHAR(200) NOT NULL,
+            lifecycle_status VARCHAR(20) NOT NULL
+                CHECK (lifecycle_status IN ('draft','active','retired')),
+            current_version INTEGER NOT NULL CHECK (current_version >= 1),
+            content_hash CHAR(64) NOT NULL,
+            definition JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(definition) = 'object')
+        );
+
+        CREATE TABLE public.governance_domain_template_versions (
+            uid UUID PRIMARY KEY,
+            template_uid UUID NOT NULL
+                REFERENCES public.governance_domain_templates(uid),
+            version INTEGER NOT NULL CHECK (version >= 1),
+            content_hash CHAR(64) NOT NULL,
+            definition JSONB NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (template_uid, version),
+            CHECK (jsonb_typeof(definition) = 'object')
+        );
+
+        CREATE TABLE public.governance_object_types (
+            uid UUID PRIMARY KEY,
+            template_uid UUID NOT NULL
+                REFERENCES public.governance_domain_templates(uid),
+            type_code VARCHAR(64) NOT NULL,
+            name VARCHAR(200) NOT NULL,
+            lifecycle_status VARCHAR(20) NOT NULL
+                CHECK (lifecycle_status IN ('draft','active','retired')),
+            current_version INTEGER NOT NULL CHECK (current_version >= 1),
+            stable_uid_prefix VARCHAR(16) NOT NULL,
+            source_identity_fields JSONB NOT NULL,
+            definition JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (template_uid, type_code),
+            CHECK (jsonb_typeof(source_identity_fields) = 'array'),
+            CHECK (jsonb_array_length(source_identity_fields) > 0),
+            CHECK (jsonb_typeof(definition) = 'object')
+        );
+
+        CREATE TABLE public.governance_domain_template_imports (
+            uid UUID PRIMARY KEY,
+            template_uid UUID NOT NULL
+                REFERENCES public.governance_domain_templates(uid),
+            operation VARCHAR(20) NOT NULL
+                CHECK (operation IN ('import','rollback')),
+            status VARCHAR(20) NOT NULL
+                CHECK (status IN ('applied','failed')),
+            version INTEGER NOT NULL CHECK (version >= 1),
+            target_version INTEGER,
+            before_state JSONB,
+            after_state JSONB NOT NULL,
+            diff JSONB NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (before_state IS NULL OR jsonb_typeof(before_state) = 'object'),
+            CHECK (jsonb_typeof(after_state) = 'object'),
+            CHECK (jsonb_typeof(diff) = 'object')
+        );
+
+        CREATE INDEX idx_governance_domain_template_versions_created
+            ON public.governance_domain_template_versions(
+                template_uid, version DESC
+            );
+        CREATE INDEX idx_governance_object_types_status
+            ON public.governance_object_types(
+                template_uid, lifecycle_status, type_code
+            );
+        CREATE INDEX idx_governance_domain_template_imports_created
+            ON public.governance_domain_template_imports(
+                template_uid, created_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "governance domain template history is append-only; "
+        "schema downgrade requires an approved archival migration"
+    )

+ 199 - 0
deployment/migrations/versions/20260730_380_active_metadata_lineage.py

@@ -0,0 +1,199 @@
+"""Add active metadata discovery, field lineage and correction audit."""
+
+from alembic import op
+
+revision = "20260730_380"
+down_revision = "20260730_370"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.active_metadata_plans (
+            uid UUID PRIMARY KEY,
+            source_uid UUID NOT NULL REFERENCES public.ingestion_sources(uid),
+            name VARCHAR(300) NOT NULL,
+            source_kind VARCHAR(20) NOT NULL
+                CHECK (source_kind IN ('database','file','api')),
+            schedule_type VARCHAR(20) NOT NULL
+                CHECK (schedule_type IN ('manual','interval','cron')),
+            schedule_expression VARCHAR(120),
+            discovery_mode VARCHAR(20) NOT NULL
+                CHECK (discovery_mode IN ('snapshot','cursor')),
+            scope JSONB NOT NULL,
+            cursor_state JSONB NOT NULL DEFAULT '{}'::jsonb,
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            enabled BOOLEAN NOT NULL DEFAULT TRUE,
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(scope) = 'object'),
+            CHECK (jsonb_typeof(cursor_state) = 'object')
+        );
+
+        CREATE TABLE public.active_metadata_runs (
+            uid UUID PRIMARY KEY,
+            plan_uid UUID NOT NULL REFERENCES public.active_metadata_plans(uid),
+            batch_key VARCHAR(160) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('completed','failed')),
+            attempt_count INTEGER NOT NULL DEFAULT 1 CHECK (attempt_count > 0),
+            cursor_before JSONB NOT NULL,
+            cursor_after JSONB NOT NULL,
+            snapshot_hash CHAR(64),
+            statistics JSONB NOT NULL,
+            failure_code VARCHAR(80),
+            failure_reason VARCHAR(500),
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            started_at TIMESTAMPTZ NOT NULL,
+            finished_at TIMESTAMPTZ NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (plan_uid, batch_key),
+            CHECK (jsonb_typeof(cursor_before) = 'object'),
+            CHECK (jsonb_typeof(cursor_after) = 'object'),
+            CHECK (jsonb_typeof(statistics) = 'object')
+        );
+
+        CREATE TABLE public.active_metadata_assets (
+            uid UUID PRIMARY KEY,
+            source_uid UUID NOT NULL REFERENCES public.ingestion_sources(uid),
+            asset_key VARCHAR(500) NOT NULL,
+            namespace VARCHAR(200) NOT NULL,
+            name VARCHAR(200) NOT NULL,
+            asset_type VARCHAR(40) NOT NULL,
+            lifecycle_status VARCHAR(30) NOT NULL
+                CHECK (lifecycle_status IN ('active','deletion_candidate','retired')),
+            current_version INTEGER NOT NULL CHECK (current_version > 0),
+            content_hash CHAR(64) NOT NULL,
+            snapshot JSONB NOT NULL,
+            health JSONB NOT NULL DEFAULT '{}'::jsonb,
+            last_run_uid UUID NOT NULL REFERENCES public.active_metadata_runs(uid),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (source_uid, asset_key),
+            CHECK (jsonb_typeof(snapshot) = 'object'),
+            CHECK (jsonb_typeof(health) = 'object')
+        );
+
+        CREATE TABLE public.active_metadata_asset_versions (
+            uid UUID PRIMARY KEY,
+            asset_uid UUID NOT NULL REFERENCES public.active_metadata_assets(uid),
+            version INTEGER NOT NULL CHECK (version > 0),
+            run_uid UUID NOT NULL REFERENCES public.active_metadata_runs(uid),
+            content_hash CHAR(64) NOT NULL,
+            snapshot JSONB NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (asset_uid, version),
+            CHECK (jsonb_typeof(snapshot) = 'object')
+        );
+
+        CREATE TABLE public.active_metadata_changes (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL REFERENCES public.active_metadata_runs(uid),
+            asset_uid UUID NOT NULL REFERENCES public.active_metadata_assets(uid),
+            asset_key VARCHAR(500) NOT NULL,
+            field_name VARCHAR(200),
+            change_type VARCHAR(40) NOT NULL CHECK (
+                change_type IN (
+                    'asset_added','field_added','field_changed',
+                    'field_deletion_candidate','deletion_candidate'
+                )
+            ),
+            before_state JSONB,
+            after_state JSONB,
+            status VARCHAR(20) NOT NULL DEFAULT 'pending'
+                CHECK (status IN ('pending','accepted','rejected')),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.active_metadata_lineage (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL REFERENCES public.active_metadata_runs(uid),
+            parse_status VARCHAR(20) NOT NULL
+                CHECK (parse_status IN ('resolved','failed')),
+            source_asset VARCHAR(500),
+            source_field VARCHAR(200),
+            target_asset VARCHAR(500),
+            target_field VARCHAR(200),
+            relation_type VARCHAR(40) NOT NULL,
+            evidence JSONB NOT NULL,
+            failure_reason VARCHAR(500),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(evidence) = 'object')
+        );
+
+        CREATE TABLE public.active_metadata_health_signals (
+            uid UUID PRIMARY KEY,
+            asset_uid UUID NOT NULL REFERENCES public.active_metadata_assets(uid),
+            run_uid UUID NOT NULL REFERENCES public.active_metadata_runs(uid),
+            signal_type VARCHAR(30) NOT NULL CHECK (
+                signal_type IN ('quality','freshness','task_failure','usage')
+            ),
+            value JSONB,
+            status VARCHAR(20) NOT NULL
+                CHECK (status IN ('healthy','warning','critical','unknown')),
+            evidence JSONB NOT NULL,
+            observed_at TIMESTAMPTZ NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(evidence) = 'object')
+        );
+
+        CREATE TABLE public.active_metadata_corrections (
+            uid UUID PRIMARY KEY,
+            asset_uid UUID NOT NULL REFERENCES public.active_metadata_assets(uid),
+            field_name VARCHAR(200),
+            proposed_value JSONB NOT NULL,
+            reason VARCHAR(500) NOT NULL,
+            assignee_uid UUID NOT NULL REFERENCES public.users(id),
+            status VARCHAR(20) NOT NULL
+                CHECK (status IN ('pending','resolved','rejected')),
+            resolution JSONB NOT NULL DEFAULT '{}'::jsonb,
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            submitted_by UUID NOT NULL REFERENCES public.users(id),
+            resolved_by UUID REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(proposed_value) = 'object'),
+            CHECK (jsonb_typeof(resolution) = 'object')
+        );
+
+        CREATE TABLE public.active_metadata_correction_audits (
+            uid UUID PRIMARY KEY,
+            correction_uid UUID NOT NULL
+                REFERENCES public.active_metadata_corrections(uid),
+            version INTEGER NOT NULL CHECK (version > 0),
+            action VARCHAR(40) NOT NULL,
+            before_state JSONB,
+            after_state JSONB NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (correction_uid, version),
+            CHECK (jsonb_typeof(after_state) = 'object')
+        );
+
+        CREATE INDEX idx_active_metadata_plans_enabled
+            ON public.active_metadata_plans(enabled, source_kind);
+        CREATE INDEX idx_active_metadata_runs_plan_created
+            ON public.active_metadata_runs(plan_uid, created_at DESC);
+        CREATE INDEX idx_active_metadata_assets_source_status
+            ON public.active_metadata_assets(source_uid, lifecycle_status);
+        CREATE INDEX idx_active_metadata_changes_run_status
+            ON public.active_metadata_changes(run_uid, status, change_type);
+        CREATE INDEX idx_active_metadata_lineage_target
+            ON public.active_metadata_lineage(target_asset, target_field);
+        CREATE INDEX idx_active_metadata_health_asset
+            ON public.active_metadata_health_signals(asset_uid, observed_at DESC);
+        CREATE INDEX idx_active_metadata_corrections_assignee
+            ON public.active_metadata_corrections(assignee_uid, status);
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "active metadata history is append-only; "
+        "downgrade requires an approved archival migration"
+    )

+ 162 - 0
deployment/migrations/versions/20260731_390_semantic_governance.py

@@ -0,0 +1,162 @@
+"""Add generic semantic governance, field mappings and publication audit."""
+
+from alembic import op
+
+revision = "20260731_390"
+down_revision = "20260730_380"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.semantic_assets (
+            uid UUID PRIMARY KEY,
+            asset_kind VARCHAR(30) NOT NULL CHECK (
+                asset_kind IN ('business_term','code_set','metric')
+            ),
+            code VARCHAR(120) NOT NULL,
+            name VARCHAR(300) NOT NULL,
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            business_domain_uid UUID NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN (
+                    'draft','in_review','approved','published',
+                    'superseded','retired'
+                )
+            ),
+            current_version INTEGER NOT NULL CHECK (current_version > 0),
+            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 (asset_kind, code)
+        );
+
+        CREATE TABLE public.semantic_asset_versions (
+            uid UUID PRIMARY KEY,
+            asset_uid UUID NOT NULL REFERENCES public.semantic_assets(uid),
+            version INTEGER NOT NULL CHECK (version > 0),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN (
+                    'draft','in_review','approved','published',
+                    'superseded','retired'
+                )
+            ),
+            content_hash CHAR(64) NOT NULL,
+            definition JSONB NOT NULL,
+            change_reason VARCHAR(1000) NOT NULL,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            rollback_from_version INTEGER,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            published_at TIMESTAMPTZ,
+            UNIQUE (asset_uid, version),
+            CHECK (jsonb_typeof(definition) = 'object'),
+            CHECK (
+                rollback_from_version IS NULL
+                OR rollback_from_version > 0
+            )
+        );
+
+        CREATE TABLE public.semantic_asset_reviews (
+            uid UUID PRIMARY KEY,
+            asset_uid UUID NOT NULL REFERENCES public.semantic_assets(uid),
+            version INTEGER NOT NULL CHECK (version > 0),
+            decision VARCHAR(20) NOT NULL
+                CHECK (decision IN ('approve','reject')),
+            reason VARCHAR(1000) NOT NULL,
+            reviewer_uid UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (asset_uid, version),
+            FOREIGN KEY (asset_uid, version)
+                REFERENCES public.semantic_asset_versions(asset_uid, version)
+        );
+
+        CREATE TABLE public.semantic_asset_links (
+            uid UUID PRIMARY KEY,
+            asset_uid UUID NOT NULL REFERENCES public.semantic_assets(uid),
+            version INTEGER NOT NULL CHECK (version > 0),
+            link_type VARCHAR(30) NOT NULL CHECK (
+                link_type IN ('asset','data_element','standard','code_value')
+            ),
+            target_type VARCHAR(60) NOT NULL,
+            target_uid VARCHAR(200) NOT NULL,
+            target_key VARCHAR(200),
+            metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            FOREIGN KEY (asset_uid, version)
+                REFERENCES public.semantic_asset_versions(asset_uid, version),
+            UNIQUE (
+                asset_uid, version, link_type, target_type,
+                target_uid, target_key
+            ),
+            CHECK (jsonb_typeof(metadata) = 'object')
+        );
+
+        CREATE TABLE public.data_element_field_mappings (
+            uid UUID PRIMARY KEY,
+            asset_uid UUID NOT NULL
+                REFERENCES public.active_metadata_assets(uid),
+            field_name VARCHAR(200) NOT NULL,
+            data_element_uid UUID NOT NULL REFERENCES public.data_elements(uid),
+            data_element_version INTEGER NOT NULL CHECK (
+                data_element_version > 0
+            ),
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            status VARCHAR(20) NOT NULL
+                CHECK (status IN ('published','retired')),
+            evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
+            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 (asset_uid, field_name, data_element_uid),
+            CHECK (jsonb_typeof(evidence) = 'object')
+        );
+
+        CREATE TABLE public.semantic_publication_audits (
+            uid UUID PRIMARY KEY,
+            target_type VARCHAR(40) NOT NULL CHECK (
+                target_type IN (
+                    'semantic_asset','data_standard','field_mapping'
+                )
+            ),
+            target_uid UUID NOT NULL,
+            target_version INTEGER NOT NULL CHECK (target_version > 0),
+            action VARCHAR(40) NOT NULL CHECK (
+                action IN (
+                    'created','revised','submitted','approved','rejected',
+                    'published','rolled_back','mapped','retired'
+                )
+            ),
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            detail JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(detail) = 'object')
+        );
+
+        CREATE INDEX idx_semantic_assets_kind_status
+            ON public.semantic_assets(asset_kind, status, updated_at DESC);
+        CREATE INDEX idx_semantic_versions_asset
+            ON public.semantic_asset_versions(asset_uid, version DESC);
+        CREATE INDEX idx_semantic_links_target
+            ON public.semantic_asset_links(target_type, target_uid, target_key);
+        CREATE UNIQUE INDEX uq_published_physical_field_mapping
+            ON public.data_element_field_mappings(asset_uid, field_name)
+            WHERE status = 'published';
+        CREATE INDEX idx_field_mappings_element
+            ON public.data_element_field_mappings(
+                data_element_uid, status, updated_at DESC
+            );
+        CREATE INDEX idx_semantic_audits_target
+            ON public.semantic_publication_audits(
+                target_type, target_uid, target_version
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "semantic governance history is append-only; "
+        "downgrade requires an approved archival migration"
+    )

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

@@ -0,0 +1,183 @@
+"""Add generic deterministic quality profiling, findings and SLA events."""
+
+from alembic import op
+
+revision = "20260731_400"
+down_revision = "20260731_390"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.quality_templates (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','retired')
+            ),
+            current_version INTEGER NOT NULL CHECK (current_version > 0),
+            active_version_uid UUID,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.quality_template_versions (
+            uid UUID PRIMARY KEY,
+            template_uid UUID NOT NULL
+                REFERENCES public.quality_templates(uid),
+            version INTEGER NOT NULL CHECK (version > 0),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','superseded')
+            ),
+            definition JSONB NOT NULL,
+            content_hash CHAR(64) NOT NULL,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            published_by UUID REFERENCES public.users(id),
+            published_at TIMESTAMPTZ,
+            UNIQUE (template_uid, version),
+            UNIQUE (template_uid, content_hash),
+            CHECK (jsonb_typeof(definition) = 'object')
+        );
+
+        ALTER TABLE public.quality_templates
+            ADD CONSTRAINT fk_quality_template_active_version
+            FOREIGN KEY (active_version_uid)
+            REFERENCES public.quality_template_versions(uid);
+
+        CREATE UNIQUE INDEX uq_quality_template_published_version
+            ON public.quality_template_versions(template_uid)
+            WHERE status = 'published';
+
+        CREATE TABLE public.quality_profile_runs (
+            uid UUID PRIMARY KEY,
+            template_uid UUID NOT NULL
+                REFERENCES public.quality_templates(uid),
+            template_version_uid UUID NOT NULL
+                REFERENCES public.quality_template_versions(uid),
+            template_hash CHAR(64) NOT NULL,
+            asset_uid UUID NOT NULL
+                REFERENCES public.active_metadata_assets(uid),
+            source_uid UUID NOT NULL
+                REFERENCES public.ingestion_sources(uid),
+            business_domain_uid VARCHAR(200),
+            batch_key VARCHAR(160) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('success','failed')),
+            row_count INTEGER NOT NULL CHECK (row_count > 0),
+            score NUMERIC(6,2) NOT NULL CHECK (
+                score >= 0 AND score <= 100
+            ),
+            source_observed_at TIMESTAMPTZ NOT NULL,
+            previous_run_uid UUID
+                REFERENCES public.quality_profile_runs(uid),
+            comparison JSONB NOT NULL,
+            profile JSONB NOT NULL,
+            field_bindings JSONB NOT NULL,
+            finding_count INTEGER NOT NULL CHECK (finding_count >= 0),
+            deterministic BOOLEAN NOT NULL DEFAULT TRUE CHECK (deterministic),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (asset_uid, batch_key),
+            CHECK (jsonb_typeof(comparison) = 'object'),
+            CHECK (jsonb_typeof(profile) = 'object'),
+            CHECK (jsonb_typeof(field_bindings) = 'object')
+        );
+
+        CREATE TABLE public.quality_profile_metrics (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.quality_profile_runs(uid) ON DELETE CASCADE,
+            field_name VARCHAR(200) NOT NULL,
+            metric_value JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (run_uid, field_name),
+            CHECK (jsonb_typeof(metric_value) = 'object')
+        );
+
+        CREATE TABLE public.quality_findings (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.quality_profile_runs(uid) ON DELETE CASCADE,
+            asset_uid UUID NOT NULL
+                REFERENCES public.active_metadata_assets(uid),
+            finding_type VARCHAR(30) NOT NULL CHECK (
+                finding_type IN (
+                    'completeness','uniqueness','pattern','duplicate',
+                    'outlier','volume','distribution','schema','freshness'
+                )
+            ),
+            field_name VARCHAR(200),
+            severity VARCHAR(20) NOT NULL CHECK (
+                severity IN ('info','warning','error','critical')
+            ),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('open','acknowledged','resolved')
+            ),
+            actual JSONB NOT NULL,
+            expected JSONB NOT NULL,
+            recurrence_key CHAR(64) NOT NULL,
+            occurrence_number INTEGER NOT NULL CHECK (occurrence_number > 0),
+            evidence JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(actual) IN ('object','number','string')),
+            CHECK (jsonb_typeof(expected) IN ('object','number','string')),
+            CHECK (jsonb_typeof(evidence) = 'object')
+        );
+
+        CREATE TABLE public.quality_sla_events (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.quality_profile_runs(uid) ON DELETE CASCADE,
+            asset_uid UUID NOT NULL
+                REFERENCES public.active_metadata_assets(uid),
+            sla_type VARCHAR(30) NOT NULL CHECK (
+                sla_type IN ('freshness','quality_score')
+            ),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('met','violated','recovered')
+            ),
+            severity VARCHAR(20) NOT NULL CHECK (
+                severity IN ('info','warning','error','critical')
+            ),
+            actual NUMERIC NOT NULL,
+            threshold NUMERIC NOT NULL,
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            escalation_level INTEGER NOT NULL CHECK (
+                escalation_level BETWEEN 0 AND 3
+            ),
+            evidence JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (run_uid, sla_type),
+            CHECK (jsonb_typeof(evidence) = 'object')
+        );
+
+        CREATE INDEX idx_quality_runs_asset_created
+            ON public.quality_profile_runs(asset_uid, created_at DESC);
+        CREATE INDEX idx_quality_runs_domain_created
+            ON public.quality_profile_runs(
+                business_domain_uid, created_at DESC
+            );
+        CREATE INDEX idx_quality_findings_recurrence
+            ON public.quality_findings(
+                recurrence_key, occurrence_number DESC
+            );
+        CREATE INDEX idx_quality_findings_asset_status
+            ON public.quality_findings(asset_uid, status, created_at DESC);
+        CREATE INDEX idx_quality_sla_asset_status
+            ON public.quality_sla_events(
+                asset_uid, sla_type, status, created_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "quality operation evidence is append-only; "
+        "downgrade requires an approved archival migration"
+    )

+ 11 - 5
docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md

@@ -269,11 +269,11 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 
 **主要工作:**
 
-- [ ] 建设通用画像:完整率、唯一性、分布、空值、模式和样例统计。
-- [ ] 建设质量趋势、同比/环比和退化识别。
-- [ ] 建设数据量、分布、模式、重复和异常值检测。
-- [ ] 建设新鲜度、质量 SLA、违约和升级事件。
-- [ ] 将复发和根因分析关联到血缘、变更、运行和责任证据。
+- [x] 建设通用画像:完整率、唯一性、分布、空值、模式和样例统计。
+- [x] 建设质量趋势、同比/环比和退化识别。
+- [x] 建设数据量、分布、模式、重复和异常值检测。
+- [x] 建设新鲜度、质量 SLA、违约和升级事件。
+- [x] 将复发和根因分析关联到血缘、变更、运行和责任证据。
 
 **主要文件区域:**
 
@@ -287,6 +287,12 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 **完成门禁:** 同一质量模板可在设备域和第二业务域运行;结果均能下钻到资产、
 规则、来源和执行批次;不以 AI 推测替代确定性质量结果。
 
+**工程状态:** 已完成本地工程门禁。同一语义角色模板已在设备域和默认第二
+业务域“备品备件/物料主数据”通过契约与 PostgreSQL 集成验证,结果可下钻到
+资产、模板版本、来源、批次、字段画像、异常、SLA 及血缘/变更/运行/责任证据。
+企业真实数据、正式责任人、质量阈值及运营流程仍须在 P2-WP12、P2-WP13 验收,
+当前不等同生产就绪。
+
 ### P2-WP05 数据可观测与事故
 
 **目标:** 将质量和运行异常组织成可运营的数据事故闭环。

+ 9 - 9
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -584,18 +584,18 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | 模块编号 | 模块分级 | 功能项 | 成熟度 |
 |---|---|---|---|
 | DQA-01 | 数据质量 / 规则定义 | 自然语言规则、封闭 RuleSpec 和版本 | 工程完成,受门禁 |
-| DQA-02 | 数据质量 / 数据画像 | 完整率、唯一性、分布、空值、模式和样例画像 | 部分建设;已形成设备域完整性、唯一性和规则通过率画像,通用分布/模式画像待建设 |
+| DQA-02 | 数据质量 / 数据画像 | 完整率、唯一性、分布、空值、模式和样例画像 | 工程完成,待企业数据验收;通用画像覆盖完整率、唯一性、分布、空值、模式及不可逆样例摘要 |
 | DQA-03 | 数据质量 / 规则执行 | SQL 下推、Polars 批处理和 `quality.check` | 工程完成,受门禁 |
-| DQA-04 | 数据质量 / 质量评分 | 资产、数据产品和业务域的质量评分 | 部分建设;已形成设备资产和设备质量检查整体评分,数据产品及通用业务域评分待建设 |
-| DQA-05 | 数据质量 / 质量趋势 | 质量指标时间序列、同比、环比和退化趋势 | 规划中 |
-| DQA-06 | 数据质量 / 异常检测 | 数据量、分布、模式、重复和异常值检测 | 规划中 |
-| DQA-07 | 数据质量 / Schema 漂移 | 字段、类型、约束和枚举变化检测 | 部分建设 |
-| DQA-08 | 数据质量 / 新鲜度 | 数据更新时间、延迟和过期检测 | 规划中 |
-| DQA-09 | 数据质量 / 质量 SLA | 质量目标、阈值、窗口、违约和升级 | 规划中 |
+| DQA-04 | 数据质量 / 质量评分 | 资产、数据产品和业务域的质量评分 | 部分建设;通用资产执行评分及业务域归属已形成,数据产品评分和业务域聚合评分待 P2-WP08、P2-WP12 验收 |
+| DQA-05 | 数据质量 / 质量趋势 | 质量指标时间序列、同比、环比和退化趋势 | 工程完成,待企业统计窗口验收;已形成批次时间序列、上批退化、同比和环比比较 |
+| DQA-06 | 数据质量 / 异常检测 | 数据量、分布、模式、重复和异常值检测 | 工程完成,待企业阈值验收;所有发现均由确定性统计生成 |
+| DQA-07 | 数据质量 / Schema 漂移 | 字段、类型、约束和枚举变化检测 | 部分建设;主动元数据字段变化和质量画像 Schema 退化已形成,通用约束及枚举漂移待扩展 |
+| DQA-08 | 数据质量 / 新鲜度 | 数据更新时间、延迟和过期检测 | 工程完成,待企业时间源与阈值验收 |
+| DQA-09 | 数据质量 / 质量 SLA | 质量目标、阈值、窗口、违约和升级 | 工程完成,待 P2-WP05/P2-WP07 事故与通知联调;已形成新鲜度、质量得分违约、恢复和升级级别事件 |
 | DQA-10 | 质量运营 / 质量问题 | 问题创建、分类、影响、责任人和优先级 | 工程完成,待企业质量问题验收 |
 | DQA-11 | 质量运营 / 整改工单 | 分派、整改、复核、关闭、重开和逾期 | 工程完成,待企业整改流程验收 |
-| DQA-12 | 质量运营 / 复发分析 | 重复问题识别、复发率和治理效果 | 部分建设;确定性复发身份、次数、问题组和复发率已形成,跨资产趋势及治理效果分析待建设 |
-| DQA-13 | 质量智能 / 根因分析 | 结合血缘、变更和运行证据分析根因 | 部分建设;设备运行事件和关系证据路径已形成,通用血缘、变更证据融合及专家验收待建设 |
+| DQA-12 | 质量运营 / 复发分析 | 重复问题识别、复发率和治理效果 | 部分建设;设备问题组及通用质量发现的确定性复发次数、质量趋势已形成,跨资产治理效果聚合待 P2-WP12 验收 |
+| DQA-13 | 质量智能 / 根因分析 | 结合血缘、变更和运行证据分析根因 | 部分建设;设备关系路径及通用血缘、变更、运行和责任证据已融合,不做无证据因果推断,专家验收待完成 |
 | DQA-14 | 质量智能 / 影响分析 | 识别受影响资产、产品、报表、Agent 和业务域 | 部分建设;设备资产、运行事件和质量问题的关系影响已形成,产品、报表、Agent 和业务域影响待建设 |
 | DQA-15 | 质量智能 / 修复建议 | AI 生成受证据约束的修复建议 | 规划中;WP-09 只返回根因候选,不生成修复动作 |
 | DQA-16 | 质量智能 / 自动修复 | 低风险受控执行,高风险人工审批和回滚 | 规划中 |

+ 22 - 0
docs/architecture/DATA_MODEL.md

@@ -332,6 +332,27 @@ JSON/OWL 交换接口,不把普通语义 JSON 冒充 OWL。
 embedding profile 与模型、维度一致时才同步 PostgreSQL canonical 治理知识。配置未就绪时
 事件保留为 pending,不跳过授权、不降级为未受治理向量,也不让 LightRAG 旁路成为源真相。
 
+## 4.6 P2-WP04 通用质量运营
+
+通用质量模板使用语义字段角色和执行绑定复用质量策略,不把设备或备品备件物理字段写入
+平台模板。模板和版本使用 canonical JSON 哈希;只有已发布版本可执行。每次执行固定关联
+主动元数据资产、来源、模板版本和批次键,同一资产内批次键唯一。
+
+| 数据对象 | 作用 | 关键约束 |
+|---|---|---|
+| `quality_templates` | 通用质量模板当前态 | 编码唯一,显式责任人、当前版本和活动发布版本 |
+| `quality_template_versions` | 不可变模板版本 | 定义哈希去重,同一模板只允许一个已发布版本 |
+| `quality_profile_runs` | 确定性画像执行批次 | 关联资产、来源、模板版本、业务域、上批次和字段绑定;拒绝 AI 判定 |
+| `quality_profile_metrics` | 字段画像 | 保存完整率、唯一性、空值、类型、分布、模式和不可逆样例摘要 |
+| `quality_findings` | 异常与退化发现 | 保存稳定复发键、发生次数、实际/阈值以及血缘、变更、运行和责任证据 |
+| `quality_sla_events` | 新鲜度和质量得分 SLA | 保存达标、违约、恢复、责任人和升级级别 |
+
+执行输入限制为最多 5,000 行、每行最多 200 个标量字段、总计 200,000 个单元格和
+800 万字符,并拒绝高风险正则。分布和样例不保存原始值,只保存不可逆 SHA-256 摘要。
+服务端从主动元数据读取资产、血缘、变更和采集运行证据,客户端不能提交根因证据覆盖
+canonical 数据。画像与异常只用于数据运营与治理,不提供任意 SQL、在线分析、BI 计算或
+自动修复。
+
 ## 5. 所有权与删除规则
 
 - PostgreSQL 是身份、权限、映射、任务状态、布局和一致性事件的源真相。
@@ -351,6 +372,7 @@ embedding profile 与模型、维度一致时才同步 PostgreSQL canonical 治
 - 领域模板、模板版本、通用对象类型和导入审计以 PostgreSQL 为源真相;模板只描述对象契约,不替代设备台账或复制第二套资产服务。模板回滚追加新版本,被移除对象类型只退役、不删除。
 - 主动元数据计划、批次、资产当前态、不可变版本、变化候选、字段血缘、健康信号和纠错审计以 PostgreSQL 为源真相;现有目录快照继续作为批次输入证据。删除只形成候选,解析失败保留原因,发现或纠错不能绕过既有发布门禁覆盖 Neo4j 已发布元数据。
 - 业务术语、通用代码集、指标口径、物理字段映射、不可变版本、独立审批和发布审计以 PostgreSQL 为源真相;标准版本继续复用既有不可变发布门禁。指标口径不是执行计划,发布后通过 outbox 同步 canonical 治理知识,知识同步配置未就绪时事件不得丢失。
+- 通用质量模板、版本、画像批次、字段指标、异常发现和 SLA 事件以 PostgreSQL 为源真相;主动元数据资产是质量对象身份和根因证据的权威来源。复发次数和升级级别为确定性运营证据,不等同于自动因果结论或自动修复授权。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

File diff suppressed because it is too large
+ 1265 - 155
docs/architecture/OPENAPI.yaml


+ 70 - 0
docs/phase2/P2_WP04_QUALITY_OPERATIONS.md

@@ -0,0 +1,70 @@
+# P2-WP04 通用质量运营工程说明
+
+## 1. 交付结论
+
+P2-WP04 已完成本地工程实现和定向验证。平台新增跨业务域质量模板、字段角色
+绑定、确定性画像、异常与退化识别、新鲜度和质量 SLA,并将质量发现关联到
+主动元数据资产、来源、执行批次、血缘、变更、运行及模板责任人证据。
+
+这项结论只代表分支内工程门禁通过,不代表企业业务验收、生产部署或正式阈值
+已确认。
+
+## 2. 平台化设计
+
+通用质量模板定义 `identity`、`category`、`measure` 等语义角色,不直接写入
+设备域或备件域字段名。每次执行通过字段角色绑定连接物理字段,因此同一模板
+可以分别绑定:
+
+- 设备域:`device_code`、`device_type`、`temperature`;
+- 备品备件域:`part_code`、`part_category`、`stock_quantity`。
+
+设备质量原有 API、数据和整改链保持不变。通用能力使用主动元数据资产作为
+权威资产身份,不复制第二套设备台账或采集服务。
+
+## 3. 功能范围
+
+- 画像:完整率、唯一性、分布、空值、类型、模式和不可逆样例摘要;
+- 趋势:批次时间序列、上批对比、同比、环比和得分退化;
+- 异常:数据量、分布、模式、重复、异常值及 Schema 变化;
+- SLA:新鲜度与质量得分阈值、达标、违约、恢复和升级级别事件;
+- 复发:按模板、资产、发现类型和字段计算稳定复发键与发生次数;
+- 根因证据:关联主动元数据血缘、变更、采集运行和模板责任人;
+- 权限:viewer 只读,editor 可编辑和执行,admin 可发布;
+- 安全:每批最多 5,000 行、每行最多 200 字段且总计不超过 200,000 个
+  单元格/800 万字符,拒绝秘密字段和高风险正则进入模板,样例与分布值只
+  持久化不可逆摘要。
+
+## 4. 明确边界
+
+- 不使用 AI 生成或替代确定性质量结论;
+- 不建设任意 SQL、在线分析、BI 计算或自动数据修复;
+- 不把客户端提交的血缘或根因作为权威证据,根因关联由服务端读取治理数据;
+- 不在本工作包完成质量事故、统一待办和通知送达;分别由 P2-WP05、
+  P2-WP07 接续;
+- 不将本地备品备件样本当作企业真实数据验收。
+
+## 5. 数据与接口
+
+数据库迁移 `20260731_400` 新增:
+
+- `quality_templates`、`quality_template_versions`;
+- `quality_profile_runs`、`quality_profile_metrics`;
+- `quality_findings`、`quality_sla_events`。
+
+接口位于 `/api/rules/quality-operations`,覆盖模板创建、修订、发布、执行、
+批次明细和资产趋势。前端入口为
+`/data-governance/development/quality-operations`。
+
+## 6. 定向验证
+
+本工作包只执行修改范围内验证:
+
+- 通用质量核心契约与统计测试;
+- API 权限和响应契约;
+- 前端入口与能力文案契约;
+- PostgreSQL 双业务域、持久化和根因证据集成测试;
+- 迁移升级、OpenAPI 可重现性、前端定向检查和生产构建;
+- `app/` 与 `deployment/app/` 发布副本一致性。
+
+企业验收仍需提供真实责任人、脱敏真实数据、正式时间源、质量阈值和统计窗口,
+并在 P2-WP12、P2-WP13 完成业务复核。

+ 18 - 0
frontend/src/api/qualityOperations.js

@@ -0,0 +1,18 @@
+import http from '@/utils/request'
+
+const BASE = '/rules/quality-operations'
+
+export const getQualityTemplates = () => http.get(`${BASE}/templates`)
+export const createQualityTemplate = params => http.post(`${BASE}/templates`, params)
+export const reviseQualityTemplate = (uid, params) => http.post(
+  `${BASE}/templates/${uid}/revisions`,
+  params
+)
+export const publishQualityTemplate = (uid, expectedVersion) => http.post(
+  `${BASE}/templates/${uid}/publish`,
+  { expected_version: expectedVersion }
+)
+export const executeQualityProfile = params => http.post(`${BASE}/execute`, params)
+export const getQualityRuns = params => http.get(`${BASE}/runs`, params)
+export const getQualityRun = uid => http.get(`${BASE}/runs/${uid}`)
+export const getQualityTrend = assetUid => http.get(`${BASE}/assets/${assetUid}/trend`)

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

@@ -276,6 +276,18 @@ export default {
           name: 'deviceQuality',
           alwaysShow: 0
         },
+        {
+          hidden: 1,
+          type: 1,
+          title: '通用质量运营',
+          path: '/data-governance/development/quality-operations',
+          children: [],
+          label: '通用质量运营',
+          component: 'dataGovernance/development/qualityOperations',
+          meta: { roles: ['viewer', 'editor', 'admin'], title: '通用质量运营', readOnly: 'viewer' },
+          name: 'qualityOperations',
+          alwaysShow: 0
+        },
         {
           hidden: 1,
           type: 1,

+ 1 - 0
frontend/src/views/dataGovernance/development/index.vue

@@ -28,6 +28,7 @@ export default {
       { title: '领域模板', description: '配置通用对象类型、责任、规则、指标与初始化数据', icon: 'mdi-shape-plus-outline', path: '/data-governance/development/domain-templates' },
       { title: '实体匹配', description: '跨来源匹配候选、审核、非破坏性合并与回滚', icon: 'mdi-vector-link', path: '/data-governance/development/entity-resolution' },
       { title: '设备质量', description: '规则版本、质量检查、违规样本与资产评分', icon: 'mdi-shield-check-outline', path: '/data-governance/development/device-quality' },
+      { title: '通用质量运营', description: '跨域质量模板、画像、趋势、异常、新鲜度与 SLA 证据', icon: 'mdi-chart-timeline-variant-shimmer', path: '/data-governance/development/quality-operations' },
       { title: '设备关系与根因', description: '追溯设备运行事件关系,查看有证据约束的根因候选', icon: 'mdi-vector-polyline', path: '/data-governance/development/device-observability' },
       { title: '治理运营指标', description: '实时查看完整率、责任覆盖、实体映射与质量问题闭环', icon: 'mdi-chart-box-outline', path: '/data-governance/development/governance-metrics' },
       { title: '本体中心', description: '跨业务域本体定义、校验、发布与回滚', icon: 'mdi-graph-outline', path: '/data-governance/ontology' }

+ 508 - 0
frontend/src/views/dataGovernance/development/qualityOperations.vue

@@ -0,0 +1,508 @@
+<template>
+  <div class="pa-6 quality-operations">
+    <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">
+          用跨域质量模板统一画像、趋势、异常、新鲜度与质量 SLA,所有判定均保留执行证据。
+        </p>
+      </div>
+      <div class="mt-2">
+        <v-btn
+          v-if="canEdit"
+          outlined
+          color="primary"
+          class="mr-2"
+          @click="openTemplateDialog"
+        >新建跨域质量模板</v-btn>
+        <v-btn
+          v-if="canExecute"
+          color="primary"
+          @click="runDialog = true"
+        >执行质量画像</v-btn>
+      </div>
+    </div>
+
+    <v-alert type="info" outlined>
+      字段角色绑定把身份、分类、度量等语义角色映射到各业务域物理字段。
+      结果来自确定性统计,不使用 AI 推测;样例只保存不可逆摘要。
+    </v-alert>
+
+    <v-row class="mb-2">
+      <v-col cols="12" md="4">
+        <v-card outlined class="pa-4 fill-height">
+          <div class="text-overline">基础画像</div>
+          <div>完整率、唯一性、分布、空值、模式和样例统计</div>
+        </v-card>
+      </v-col>
+      <v-col cols="12" md="4">
+        <v-card outlined class="pa-4 fill-height">
+          <div class="text-overline">退化识别</div>
+          <div>数据量、Schema、重复、异常值、同比与环比趋势</div>
+        </v-card>
+      </v-col>
+      <v-col cols="12" md="4">
+        <v-card outlined class="pa-4 fill-height">
+          <div class="text-overline">运营证据</div>
+          <div>新鲜度、质量 SLA、违约与升级,以及血缘、变更、运行和责任证据</div>
+        </v-card>
+      </v-col>
+    </v-row>
+
+    <v-tabs v-model="tab" background-color="transparent" class="mb-4">
+      <v-tab>质量模板</v-tab>
+      <v-tab>执行批次与趋势</v-tab>
+      <v-tab :disabled="!selectedRun">画像与证据</v-tab>
+    </v-tabs>
+
+    <v-card v-if="tab === 0" outlined>
+      <v-card-title>
+        跨域质量模板
+        <v-spacer />
+        <v-btn icon :loading="loading" @click="loadTemplates">
+          <v-icon>mdi-refresh</v-icon>
+        </v-btn>
+      </v-card-title>
+      <v-data-table :headers="templateHeaders" :items="templates" :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="canEdit"
+            text
+            small
+            color="primary"
+            @click="openRevisionDialog(item)"
+          >修订</v-btn>
+          <v-btn
+            v-if="canPublish && item.status === 'draft'"
+            text
+            small
+            color="success"
+            @click="publishTemplate(item)"
+          >发布</v-btn>
+        </template>
+        <template #no-data>
+          <div class="py-8 text--secondary">暂无通用质量模板。</div>
+        </template>
+      </v-data-table>
+    </v-card>
+
+    <v-card v-else-if="tab === 1" outlined>
+      <v-card-title>
+        执行批次
+        <v-spacer />
+        <v-text-field
+          v-model="assetFilter"
+          dense
+          outlined
+          hide-details
+          clearable
+          label="资产 UID"
+          class="asset-filter mr-2"
+        />
+        <v-btn outlined color="primary" @click="loadRuns">查询</v-btn>
+      </v-card-title>
+      <v-data-table :headers="runHeaders" :items="runs" :loading="loading">
+        <template v-slot:[`item.score`]="{ item }">
+          <strong>{{ formatScore(item.score) }}</strong>
+        </template>
+        <template v-slot:[`item.deterministic`]="{ item }">
+          <v-chip small color="primary" outlined>
+            {{ item.deterministic ? '确定性结果' : '未知' }}
+          </v-chip>
+        </template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <v-btn text small color="primary" @click="openRun(item)">查看画像</v-btn>
+          <v-btn text small @click="loadTrend(item.asset_uid)">趋势</v-btn>
+        </template>
+      </v-data-table>
+      <v-divider />
+      <v-card-text v-if="trend">
+        <div class="text-subtitle-1 mb-2">质量趋势</div>
+        <div class="d-flex flex-wrap">
+          <v-chip class="mr-2 mb-2" outlined>
+            环比:{{ deltaLabel(trend.month_over_month) }}
+          </v-chip>
+          <v-chip class="mr-2 mb-2" outlined>
+            同比:{{ deltaLabel(trend.year_over_year) }}
+          </v-chip>
+          <v-chip class="mr-2 mb-2" outlined>
+            最近变化:{{ trend.latest ? trend.latest.score_delta : '—' }}
+          </v-chip>
+        </div>
+      </v-card-text>
+    </v-card>
+
+    <v-card v-else outlined>
+      <v-card-title>画像、异常和 SLA 证据</v-card-title>
+      <v-card-text v-if="selectedRun">
+        <v-row>
+          <v-col cols="6" md="3"><strong>资产</strong><br>{{ selectedRun.asset_uid }}</v-col>
+          <v-col cols="6" md="3"><strong>批次</strong><br>{{ selectedRun.batch_key }}</v-col>
+          <v-col cols="6" md="3"><strong>得分</strong><br>{{ formatScore(selectedRun.score) }}</v-col>
+          <v-col cols="6" md="3"><strong>发现数</strong><br>{{ selectedRun.finding_count }}</v-col>
+        </v-row>
+        <v-tabs v-model="detailTab" class="mt-4">
+          <v-tab>字段画像</v-tab>
+          <v-tab>异常发现</v-tab>
+          <v-tab>质量 SLA</v-tab>
+        </v-tabs>
+        <v-data-table
+          v-if="detailTab === 0"
+          :headers="metricHeaders"
+          :items="selectedRun.metrics || []"
+        >
+          <template v-slot:[`item.metric_value`]="{ item }">
+            <pre class="json-preview">{{ pretty(item.metric_value) }}</pre>
+          </template>
+        </v-data-table>
+        <v-data-table
+          v-else-if="detailTab === 1"
+          :headers="findingHeaders"
+          :items="selectedRun.findings || []"
+        >
+          <template v-slot:[`item.finding_type`]="{ item }">
+            {{ findingName(item.finding_type) }}
+          </template>
+          <template v-slot:[`item.evidence`]="{ item }">
+            <span>
+              第 {{ item.occurrence_number }} 次;
+              血缘 {{ evidenceCount(item, 'lineage') }}、
+              变更 {{ evidenceCount(item, 'changes') }}、
+              运行 {{ evidenceCount(item, 'runs') }}
+            </span>
+          </template>
+        </v-data-table>
+        <v-data-table
+          v-else
+          :headers="slaHeaders"
+          :items="selectedRun.sla_events || []"
+        >
+          <template v-slot:[`item.status`]="{ item }">
+            <v-chip small outlined :color="statusView(item.status).color">
+              {{ statusView(item.status).label }}
+            </v-chip>
+          </template>
+        </v-data-table>
+      </v-card-text>
+    </v-card>
+
+    <v-dialog v-model="templateDialog" max-width="820">
+      <v-card>
+        <v-card-title>{{ editingTemplate ? '修订质量模板' : '新建跨域质量模板' }}</v-card-title>
+        <v-card-text>
+          <v-text-field v-if="!editingTemplate" v-model="templateForm.code" label="模板编码" />
+          <v-text-field v-if="!editingTemplate" v-model="templateForm.name" label="模板名称" />
+          <v-text-field v-if="!editingTemplate" v-model="templateForm.owner_uid" label="责任人 UID" />
+          <v-textarea
+            v-model="definitionText"
+            rows="18"
+            label="模板定义 JSON"
+            hint="定义字段角色、检查类型、画像阈值、新鲜度和质量 SLA"
+            persistent-hint
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="templateDialog = false">取消</v-btn>
+          <v-btn color="primary" :loading="saving" @click="saveTemplate">保存草稿</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="runDialog" max-width="860">
+      <v-card>
+        <v-card-title>执行质量画像</v-card-title>
+        <v-card-text>
+          <v-select
+            v-model="runForm.template_uid"
+            :items="publishedTemplates"
+            item-text="name"
+            item-value="uid"
+            label="已发布模板"
+          />
+          <v-text-field v-model="runForm.asset_uid" label="主动元数据资产 UID" />
+          <v-text-field v-model="runForm.batch_key" label="执行批次键" />
+          <v-text-field v-model="runForm.source_observed_at" label="数据观测时间(ISO-8601)" />
+          <v-textarea v-model="bindingsText" rows="5" label="字段角色绑定 JSON" />
+          <v-textarea v-model="recordsText" rows="12" label="受控样本记录 JSON 数组" />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="runDialog = false">取消</v-btn>
+          <v-btn color="primary" :loading="saving" @click="executeProfile">执行</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  createQualityTemplate,
+  executeQualityProfile,
+  getQualityRun,
+  getQualityRuns,
+  getQualityTemplates,
+  getQualityTrend,
+  publishQualityTemplate,
+  reviseQualityTemplate
+} from '@/api/qualityOperations'
+import {
+  canEditQualityTemplate,
+  canExecuteQualityProfile,
+  canPublishQualityTemplate,
+  findingLabel,
+  formatQualityScore,
+  qualityStatusView
+} from './qualityOperationsModel'
+
+const DEFAULT_DEFINITION = {
+  schema_version: '1.0',
+  field_roles: {
+    identity: { required: true, checks: ['unique', 'pattern'] },
+    category: { required: true, checks: ['distribution'] },
+    measure: { required: false, checks: ['outlier'] }
+  },
+  thresholds: {
+    completeness_min: 0.95,
+    uniqueness_min: 1,
+    pattern: '^[A-Z]{2,5}-[0-9]{3}$',
+    volume_change_max_ratio: 0.25,
+    distribution_drift_max: 0.2,
+    freshness_max_seconds: 3600,
+    quality_score_min: 80
+  },
+  sample_limit: 5
+}
+
+export default {
+  name: 'QualityOperations',
+  data: () => ({
+    tab: 0,
+    detailTab: 0,
+    loading: false,
+    saving: false,
+    templateDialog: false,
+    runDialog: false,
+    editingTemplate: null,
+    templates: [],
+    runs: [],
+    trend: null,
+    selectedRun: null,
+    assetFilter: '',
+    templateForm: { code: '', name: '', owner_uid: '' },
+    definitionText: JSON.stringify(DEFAULT_DEFINITION, null, 2),
+    bindingsText: JSON.stringify({
+      identity: 'asset_code',
+      category: 'asset_category',
+      measure: 'measure_value'
+    }, null, 2),
+    recordsText: '[]',
+    runForm: {
+      template_uid: '',
+      asset_uid: '',
+      batch_key: '',
+      source_observed_at: new Date().toISOString()
+    },
+    templateHeaders: [
+      { text: '编码', value: 'code' },
+      { text: '名称', value: 'name' },
+      { text: '责任人', value: 'owner_uid' },
+      { text: '版本', value: 'current_version' },
+      { text: '状态', value: 'status' },
+      { text: '操作', value: 'actions', sortable: false }
+    ],
+    runHeaders: [
+      { text: '业务域', value: 'business_domain_uid' },
+      { text: '资产', value: 'asset_uid' },
+      { text: '批次', value: 'batch_key' },
+      { text: '数据量', value: 'row_count' },
+      { text: '得分', value: 'score' },
+      { text: '异常', value: 'finding_count' },
+      { text: '结果属性', value: 'deterministic' },
+      { text: '操作', value: 'actions', sortable: false }
+    ],
+    metricHeaders: [
+      { text: '字段', value: 'field_name' },
+      { text: '画像指标', value: 'metric_value' }
+    ],
+    findingHeaders: [
+      { text: '发现类型', value: 'finding_type' },
+      { text: '字段', value: 'field_name' },
+      { text: '级别', value: 'severity' },
+      { text: '根因与复发证据', value: 'evidence' }
+    ],
+    slaHeaders: [
+      { text: 'SLA', value: 'sla_type' },
+      { text: '状态', value: 'status' },
+      { text: '实际值', value: 'actual' },
+      { text: '阈值', value: 'threshold' },
+      { text: '升级级别', value: 'escalation_level' },
+      { text: '责任人', value: 'owner_uid' }
+    ]
+  }),
+  computed: {
+    permissions () {
+      return this.$store.state.user.userInfo.permissions || []
+    },
+    canEdit () {
+      return canEditQualityTemplate(this.permissions)
+    },
+    canExecute () {
+      return canExecuteQualityProfile(this.permissions)
+    },
+    canPublish () {
+      return canPublishQualityTemplate(this.permissions)
+    },
+    publishedTemplates () {
+      return this.templates.filter(item => item.status === 'published')
+    }
+  },
+  created () {
+    Promise.all([this.loadTemplates(), this.loadRuns()])
+  },
+  methods: {
+    statusView: qualityStatusView,
+    formatScore: formatQualityScore,
+    findingName: findingLabel,
+    async loadTemplates () {
+      this.loading = true
+      try {
+        const response = await getQualityTemplates()
+        this.templates = response.data || []
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loading = false
+      }
+    },
+    async loadRuns () {
+      this.loading = true
+      try {
+        const response = await getQualityRuns(
+          this.assetFilter ? { asset_uid: this.assetFilter } : {}
+        )
+        this.runs = response.data || []
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loading = false
+      }
+    },
+    openTemplateDialog () {
+      this.editingTemplate = null
+      this.templateForm = { code: '', name: '', owner_uid: '' }
+      this.definitionText = JSON.stringify(DEFAULT_DEFINITION, null, 2)
+      this.templateDialog = true
+    },
+    openRevisionDialog (template) {
+      this.editingTemplate = template
+      this.definitionText = JSON.stringify(
+        (template.latest_version && template.latest_version.definition) ||
+          DEFAULT_DEFINITION,
+        null,
+        2
+      )
+      this.templateDialog = true
+    },
+    async saveTemplate () {
+      this.saving = true
+      try {
+        const definition = JSON.parse(this.definitionText)
+        if (this.editingTemplate) {
+          await reviseQualityTemplate(this.editingTemplate.uid, {
+            definition,
+            expected_version: this.editingTemplate.current_version
+          })
+        } else {
+          await createQualityTemplate({ ...this.templateForm, definition })
+        }
+        this.templateDialog = false
+        await this.loadTemplates()
+        this.$snackbar.success('质量模板草稿已保存')
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    async publishTemplate (template) {
+      try {
+        await publishQualityTemplate(template.uid, template.current_version)
+        await this.loadTemplates()
+        this.$snackbar.success('质量模板已发布')
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    async executeProfile () {
+      this.saving = true
+      try {
+        const response = await executeQualityProfile({
+          ...this.runForm,
+          field_bindings: JSON.parse(this.bindingsText),
+          records: JSON.parse(this.recordsText)
+        })
+        this.runDialog = false
+        await this.loadRuns()
+        await this.openRun(response.data)
+        this.$snackbar.success('确定性质量画像已完成')
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    async openRun (run) {
+      this.loading = true
+      try {
+        const response = await getQualityRun(run.uid)
+        this.selectedRun = response.data
+        this.tab = 2
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loading = false
+      }
+    },
+    async loadTrend (assetUid) {
+      try {
+        const response = await getQualityTrend(assetUid)
+        this.trend = response.data
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    evidenceCount (finding, key) {
+      const values = finding.evidence &&
+        finding.evidence.root_cause &&
+        finding.evidence.root_cause[key]
+      return Array.isArray(values) ? values.length : 0
+    },
+    deltaLabel (value) {
+      return value ? `${value.score_delta >= 0 ? '+' : ''}${value.score_delta}` : '暂无可比周期'
+    },
+    pretty (value) {
+      return JSON.stringify(value || {}, null, 2)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.asset-filter {
+  max-width: 360px;
+}
+.json-preview {
+  max-width: 680px;
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+  font-size: 12px;
+}
+</style>

+ 43 - 0
frontend/src/views/dataGovernance/development/qualityOperationsModel.js

@@ -0,0 +1,43 @@
+export function canEditQualityTemplate (permissions) {
+  return (permissions || []).includes('rules:edit')
+}
+
+export function canExecuteQualityProfile (permissions) {
+  return (permissions || []).includes('rules:execute')
+}
+
+export function canPublishQualityTemplate (permissions) {
+  return (permissions || []).includes('rules:publish')
+}
+
+export function qualityStatusView (status) {
+  const views = {
+    draft: { label: '草稿', color: 'grey' },
+    published: { label: '已发布', color: 'success' },
+    superseded: { label: '已停用', color: 'warning' },
+    met: { label: '达标', color: 'success' },
+    violated: { label: '违约', color: 'error' },
+    recovered: { label: '已恢复', color: 'primary' }
+  }
+  return views[status] || { label: status || '—', color: 'grey' }
+}
+
+export function formatQualityScore (value) {
+  const number = Number(value)
+  return Number.isFinite(number) ? number.toFixed(1) : '—'
+}
+
+export function findingLabel (type) {
+  const labels = {
+    completeness: '完整率',
+    uniqueness: '唯一性',
+    distribution: '分布',
+    pattern: '模式',
+    duplicate: '重复',
+    outlier: '异常值',
+    volume: '数据量',
+    schema: 'Schema',
+    freshness: '新鲜度'
+  }
+  return labels[type] || type || '—'
+}

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

@@ -0,0 +1,183 @@
+"""Add generic deterministic quality profiling, findings and SLA events."""
+
+from alembic import op
+
+revision = "20260731_400"
+down_revision = "20260731_390"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.quality_templates (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','retired')
+            ),
+            current_version INTEGER NOT NULL CHECK (current_version > 0),
+            active_version_uid UUID,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.quality_template_versions (
+            uid UUID PRIMARY KEY,
+            template_uid UUID NOT NULL
+                REFERENCES public.quality_templates(uid),
+            version INTEGER NOT NULL CHECK (version > 0),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('draft','published','superseded')
+            ),
+            definition JSONB NOT NULL,
+            content_hash CHAR(64) NOT NULL,
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            published_by UUID REFERENCES public.users(id),
+            published_at TIMESTAMPTZ,
+            UNIQUE (template_uid, version),
+            UNIQUE (template_uid, content_hash),
+            CHECK (jsonb_typeof(definition) = 'object')
+        );
+
+        ALTER TABLE public.quality_templates
+            ADD CONSTRAINT fk_quality_template_active_version
+            FOREIGN KEY (active_version_uid)
+            REFERENCES public.quality_template_versions(uid);
+
+        CREATE UNIQUE INDEX uq_quality_template_published_version
+            ON public.quality_template_versions(template_uid)
+            WHERE status = 'published';
+
+        CREATE TABLE public.quality_profile_runs (
+            uid UUID PRIMARY KEY,
+            template_uid UUID NOT NULL
+                REFERENCES public.quality_templates(uid),
+            template_version_uid UUID NOT NULL
+                REFERENCES public.quality_template_versions(uid),
+            template_hash CHAR(64) NOT NULL,
+            asset_uid UUID NOT NULL
+                REFERENCES public.active_metadata_assets(uid),
+            source_uid UUID NOT NULL
+                REFERENCES public.ingestion_sources(uid),
+            business_domain_uid VARCHAR(200),
+            batch_key VARCHAR(160) NOT NULL,
+            status VARCHAR(20) NOT NULL CHECK (status IN ('success','failed')),
+            row_count INTEGER NOT NULL CHECK (row_count > 0),
+            score NUMERIC(6,2) NOT NULL CHECK (
+                score >= 0 AND score <= 100
+            ),
+            source_observed_at TIMESTAMPTZ NOT NULL,
+            previous_run_uid UUID
+                REFERENCES public.quality_profile_runs(uid),
+            comparison JSONB NOT NULL,
+            profile JSONB NOT NULL,
+            field_bindings JSONB NOT NULL,
+            finding_count INTEGER NOT NULL CHECK (finding_count >= 0),
+            deterministic BOOLEAN NOT NULL DEFAULT TRUE CHECK (deterministic),
+            created_by UUID NOT NULL REFERENCES public.users(id),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (asset_uid, batch_key),
+            CHECK (jsonb_typeof(comparison) = 'object'),
+            CHECK (jsonb_typeof(profile) = 'object'),
+            CHECK (jsonb_typeof(field_bindings) = 'object')
+        );
+
+        CREATE TABLE public.quality_profile_metrics (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.quality_profile_runs(uid) ON DELETE CASCADE,
+            field_name VARCHAR(200) NOT NULL,
+            metric_value JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (run_uid, field_name),
+            CHECK (jsonb_typeof(metric_value) = 'object')
+        );
+
+        CREATE TABLE public.quality_findings (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.quality_profile_runs(uid) ON DELETE CASCADE,
+            asset_uid UUID NOT NULL
+                REFERENCES public.active_metadata_assets(uid),
+            finding_type VARCHAR(30) NOT NULL CHECK (
+                finding_type IN (
+                    'completeness','uniqueness','pattern','duplicate',
+                    'outlier','volume','distribution','schema','freshness'
+                )
+            ),
+            field_name VARCHAR(200),
+            severity VARCHAR(20) NOT NULL CHECK (
+                severity IN ('info','warning','error','critical')
+            ),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('open','acknowledged','resolved')
+            ),
+            actual JSONB NOT NULL,
+            expected JSONB NOT NULL,
+            recurrence_key CHAR(64) NOT NULL,
+            occurrence_number INTEGER NOT NULL CHECK (occurrence_number > 0),
+            evidence JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(actual) IN ('object','number','string')),
+            CHECK (jsonb_typeof(expected) IN ('object','number','string')),
+            CHECK (jsonb_typeof(evidence) = 'object')
+        );
+
+        CREATE TABLE public.quality_sla_events (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.quality_profile_runs(uid) ON DELETE CASCADE,
+            asset_uid UUID NOT NULL
+                REFERENCES public.active_metadata_assets(uid),
+            sla_type VARCHAR(30) NOT NULL CHECK (
+                sla_type IN ('freshness','quality_score')
+            ),
+            status VARCHAR(20) NOT NULL CHECK (
+                status IN ('met','violated','recovered')
+            ),
+            severity VARCHAR(20) NOT NULL CHECK (
+                severity IN ('info','warning','error','critical')
+            ),
+            actual NUMERIC NOT NULL,
+            threshold NUMERIC NOT NULL,
+            owner_uid UUID NOT NULL REFERENCES public.users(id),
+            escalation_level INTEGER NOT NULL CHECK (
+                escalation_level BETWEEN 0 AND 3
+            ),
+            evidence JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (run_uid, sla_type),
+            CHECK (jsonb_typeof(evidence) = 'object')
+        );
+
+        CREATE INDEX idx_quality_runs_asset_created
+            ON public.quality_profile_runs(asset_uid, created_at DESC);
+        CREATE INDEX idx_quality_runs_domain_created
+            ON public.quality_profile_runs(
+                business_domain_uid, created_at DESC
+            );
+        CREATE INDEX idx_quality_findings_recurrence
+            ON public.quality_findings(
+                recurrence_key, occurrence_number DESC
+            );
+        CREATE INDEX idx_quality_findings_asset_status
+            ON public.quality_findings(asset_uid, status, created_at DESC);
+        CREATE INDEX idx_quality_sla_asset_status
+            ON public.quality_sla_events(
+                asset_uid, sla_type, status, created_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "quality operation evidence is append-only; "
+        "downgrade requires an approved archival migration"
+    )

+ 4 - 5
scripts/generate_openapi.py

@@ -15,6 +15,7 @@ OUTPUT = ROOT / "docs" / "architecture" / "OPENAPI.yaml"
 PREFIXES = {
     "meta_data": "/api/meta",
     "data_interface": "/api/interface",
+    "data_rules": "/api/rules",
     "graph": "/api/graph",
     "system": "/api/system",
     "data_source": "/api/datasource",
@@ -96,10 +97,7 @@ def extract_routes() -> list[dict[str, object]]:
                     for keyword in decorator.keywords:
                         if keyword.arg == "methods":
                             methods = ast.literal_eval(keyword.value)
-                elif (
-                    module == "knowledge_base"
-                    and function.attr in SHORTHAND_METHODS
-                ):
+                elif function.attr in SHORTHAND_METHODS:
                     methods = [function.attr.upper()]
                 else:
                     continue
@@ -115,6 +113,7 @@ def extract_routes() -> list[dict[str, object]]:
                             "operation_id": f"{module}_{node.name}_{method.lower()}",
                             "summary": summary,
                             "parameters": parameters,
+                            "source": str(route_file.relative_to(ROOT)),
                         }
                     )
     return sorted(routes, key=lambda item: (str(item["path"]), str(item["method"])))
@@ -150,7 +149,7 @@ def render(routes: list[dict[str, object]]) -> str:
                     f"      tags: [{operation['tag']}]",
                     f"      operationId: {operation['operation_id']}",
                     f"      summary: {quoted(str(operation['summary']))}",
-                    f"      x-source: {quoted('app/api/' + str(operation['tag']) + '/routes.py')}",
+                    f"      x-source: {quoted(str(operation['source']))}",
                 ]
             )
             parameters = operation["parameters"]

+ 440 - 0
tests/core/data_rules/test_quality_operations.py

@@ -0,0 +1,440 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+from app.core.data_rules.quality_operations import (
+    QualityOperationsService,
+    validate_quality_template,
+)
+
+USER_UID = "01900000-0000-7000-8000-000000004001"
+TEMPLATE_UID = "01900000-0000-7000-8000-000000004002"
+VERSION_UID = "01900000-0000-7000-8000-000000004003"
+DEVICE_ASSET_UID = "01900000-0000-7000-8000-000000004004"
+PART_ASSET_UID = "01900000-0000-7000-8000-000000004005"
+SOURCE_UID = "01900000-0000-7000-8000-000000004006"
+
+
+def template_definition():
+    return {
+        "schema_version": "1.0",
+        "field_roles": {
+            "identity": {"required": True, "checks": ["unique", "pattern"]},
+            "category": {"required": True, "checks": ["distribution"]},
+            "measure": {"required": False, "checks": ["outlier"]},
+        },
+        "thresholds": {
+            "completeness_min": 0.95,
+            "uniqueness_min": 1.0,
+            "pattern": "^[A-Z]{2,5}-[0-9]{3}$",
+            "volume_change_max_ratio": 0.25,
+            "distribution_drift_max": 0.20,
+            "freshness_max_seconds": 3600,
+            "quality_score_min": 80,
+        },
+        "sample_limit": 5,
+    }
+
+
+class MemoryQualityRepository:
+    def __init__(self):
+        self.templates = {}
+        self.versions = {}
+        self.assets = {
+            DEVICE_ASSET_UID: {
+                "uid": DEVICE_ASSET_UID,
+                "source_uid": SOURCE_UID,
+                "asset_key": "maintenance.device_ledger",
+                "asset_type": "table",
+                "business_domain_uid": "device",
+                "last_run_uid": "01900000-0000-7000-8000-000000004030",
+            },
+            PART_ASSET_UID: {
+                "uid": PART_ASSET_UID,
+                "source_uid": SOURCE_UID,
+                "asset_key": "supply.spare_parts",
+                "asset_type": "table",
+                "business_domain_uid": "spare_parts",
+                "last_run_uid": "01900000-0000-7000-8000-000000004031",
+            },
+        }
+        self.runs = []
+        self.findings = []
+        self.events = []
+
+    def create_template(self, template, version):
+        self.templates[template["uid"]] = dict(template)
+        self.versions[version["uid"]] = dict(version)
+        return dict(template)
+
+    def get_template(self, uid, *, for_update=False):
+        value = self.templates.get(uid)
+        return dict(value) if value else None
+
+    def get_template_version(self, uid):
+        value = self.versions.get(uid)
+        return dict(value) if value else None
+
+    def versions_for_template(self, uid):
+        return [
+            dict(item)
+            for item in self.versions.values()
+            if item["template_uid"] == uid
+        ]
+
+    def save_template_version(self, template, version):
+        self.templates[template["uid"]] = dict(template)
+        self.versions[version["uid"]] = dict(version)
+        return dict(template)
+
+    def publish_template(self, template, version):
+        self.templates[template["uid"]] = dict(template)
+        self.versions[version["uid"]] = dict(version)
+        return dict(template)
+
+    def list_templates(self):
+        return [dict(item) for item in self.templates.values()]
+
+    def get_asset(self, uid):
+        value = self.assets.get(uid)
+        return dict(value) if value else None
+
+    def latest_run(self, asset_uid):
+        matches = [item for item in self.runs if item["asset_uid"] == asset_uid]
+        return dict(matches[-1]) if matches else None
+
+    def list_runs(self, asset_uid=None):
+        return [
+            dict(item)
+            for item in self.runs
+            if asset_uid is None or item["asset_uid"] == asset_uid
+        ]
+
+    def get_run(self, uid):
+        return next(
+            (dict(item) for item in self.runs if item["uid"] == uid),
+            None,
+        )
+
+    def recurrence_count(self, recurrence_key):
+        return sum(
+            1
+            for item in self.findings
+            if item["recurrence_key"] == recurrence_key
+        )
+
+    def latest_sla_event(self, asset_uid, sla_type):
+        values = [
+            item
+            for item in self.events
+            if item["asset_uid"] == asset_uid
+            and item["sla_type"] == sla_type
+        ]
+        return dict(values[-1]) if values else None
+
+    def related_evidence(self, asset):
+        return {
+            "asset": {
+                "uid": asset["uid"],
+                "source_uid": asset["source_uid"],
+                "asset_key": asset["asset_key"],
+            },
+            "lineage": [
+                {
+                    "uid": "01900000-0000-7000-8000-000000004040",
+                    "direction": "upstream",
+                }
+            ],
+            "changes": [
+                {
+                    "uid": "01900000-0000-7000-8000-000000004041",
+                    "change_type": "schema_changed",
+                }
+            ],
+            "runs": [{"uid": asset["last_run_uid"], "kind": "metadata"}],
+        }
+
+    def save_run(self, run, metrics, findings, events):
+        self.runs.append(dict(run))
+        self.findings.extend(dict(item) for item in findings)
+        self.events.extend(dict(item) for item in events)
+        return dict(run)
+
+    def list_metrics(self, run_uid):
+        return []
+
+    def list_findings(self, run_uid):
+        return [
+            dict(item) for item in self.findings if item["run_uid"] == run_uid
+        ]
+
+    def list_sla_events(self, run_uid):
+        return [
+            dict(item) for item in self.events if item["run_uid"] == run_uid
+        ]
+
+
+@pytest.fixture()
+def service():
+    repository = MemoryQualityRepository()
+    uids = (
+        f"01900000-0000-7000-8000-{value:012d}"
+        for value in range(4100, 4400)
+    )
+    clock = {"value": datetime(2026, 7, 31, 10, 0, tzinfo=UTC)}
+    instance = QualityOperationsService(
+        repository,
+        publish_authorizer=lambda actor_uid: None,
+        uid_factory=lambda: next(uids),
+        now_factory=lambda: clock["value"],
+    )
+    return instance, repository, clock
+
+
+def _publish_template(service):
+    created = service.create_template(
+        {
+            "code": "GENERIC_ASSET_QUALITY",
+            "name": "通用资产质量模板",
+            "owner_uid": USER_UID,
+            "definition": template_definition(),
+        },
+        actor_uid=USER_UID,
+    )
+    return service.publish_template(
+        created["uid"],
+        expected_version=1,
+        actor_uid=USER_UID,
+    )
+
+
+def test_template_contract_is_closed_and_rejects_secret_material():
+    normalized = validate_quality_template(template_definition())
+    assert normalized["field_roles"]["identity"]["checks"] == [
+        "pattern",
+        "unique",
+    ]
+
+    invalid = template_definition()
+    invalid["password"] = "must-not-enter-governance-state"
+    with pytest.raises(ValueError, match="unsupported fields|secret"):
+        validate_quality_template(invalid)
+
+    unsafe = template_definition()
+    unsafe["thresholds"]["pattern"] = "^(a+)+$"
+    with pytest.raises(ValueError, match="advanced groups"):
+        validate_quality_template(unsafe)
+
+
+def test_same_published_template_profiles_device_and_second_domain(service):
+    quality, repository, clock = service
+    template = _publish_template(quality)
+
+    device = quality.execute(
+        {
+            "template_uid": template["uid"],
+            "asset_uid": DEVICE_ASSET_UID,
+            "batch_key": "device-20260731-01",
+            "field_bindings": {
+                "identity": "device_code",
+                "category": "device_type",
+                "measure": "temperature",
+            },
+            "source_observed_at": "2026-07-31T09:45:00+00:00",
+            "records": [
+                {
+                    "device_code": "DEV-001",
+                    "device_type": "pump",
+                    "temperature": 21.0,
+                },
+                {
+                    "device_code": "DEV-002",
+                    "device_type": "pump",
+                    "temperature": 22.0,
+                },
+            ],
+        },
+        actor_uid=USER_UID,
+    )
+    clock["value"] += timedelta(minutes=5)
+    parts = quality.execute(
+        {
+            "template_uid": template["uid"],
+            "asset_uid": PART_ASSET_UID,
+            "batch_key": "parts-20260731-01",
+            "field_bindings": {
+                "identity": "part_code",
+                "category": "part_category",
+                "measure": "stock_quantity",
+            },
+            "source_observed_at": "2026-07-31T09:50:00+00:00",
+            "records": [
+                {
+                    "part_code": "PT-001",
+                    "part_category": "seal",
+                    "stock_quantity": 80,
+                },
+                {
+                    "part_code": "PT-002",
+                    "part_category": "bearing",
+                    "stock_quantity": 120,
+                },
+            ],
+        },
+        actor_uid=USER_UID,
+    )
+
+    assert device["template_version_uid"] == parts["template_version_uid"]
+    assert {
+        device["business_domain_uid"],
+        parts["business_domain_uid"],
+    } == {"device", "spare_parts"}
+    assert all(item["score"] == 100 for item in repository.runs)
+    assert all(item["deterministic"] is True for item in repository.runs)
+    assert (
+        device["profile"]["fields"]["device_code"]["pattern"]["match_rate"]
+        == 1
+    )
+
+
+def test_profile_anomalies_sla_recurrence_and_root_cause_evidence(service):
+    quality, repository, clock = service
+    template = _publish_template(quality)
+    base_payload = {
+        "template_uid": template["uid"],
+        "asset_uid": PART_ASSET_UID,
+        "field_bindings": {
+            "identity": "part_code",
+            "category": "part_category",
+            "measure": "stock_quantity",
+        },
+    }
+    quality.execute(
+        {
+            **base_payload,
+            "batch_key": "parts-baseline",
+            "source_observed_at": "2026-07-31T09:50:00+00:00",
+            "records": [
+                {
+                    "part_code": f"PT-{index:03d}",
+                    "part_category": "seal" if index < 5 else "bearing",
+                    "stock_quantity": 100 + index,
+                }
+                for index in range(1, 11)
+            ],
+        },
+        actor_uid=USER_UID,
+    )
+    clock["value"] += timedelta(hours=3)
+    degraded = quality.execute(
+        {
+            **base_payload,
+            "batch_key": "parts-degraded",
+            "source_observed_at": "2026-07-31T09:00:00+00:00",
+            "records": [
+                {
+                    "part_code": "PT-001",
+                    "part_category": "seal",
+                    "stock_quantity": 1,
+                    "obsolete_note": "legacy",
+                },
+                {
+                    "part_code": "PT-001",
+                    "part_category": "seal",
+                    "stock_quantity": 2,
+                },
+                {
+                    "part_code": None,
+                    "part_category": "seal",
+                    "stock_quantity": 5000,
+                },
+                {
+                    "part_code": "bad-code",
+                    "part_category": None,
+                    "stock_quantity": 3,
+                },
+            ],
+        },
+        actor_uid=USER_UID,
+    )
+
+    assert degraded["previous_run_uid"] is not None
+    assert degraded["comparison"]["row_count_change_ratio"] == -0.6
+    findings = repository.list_findings(degraded["uid"])
+    finding_types = {item["finding_type"] for item in findings}
+    assert {
+        "completeness",
+        "uniqueness",
+        "pattern",
+        "duplicate",
+        "outlier",
+        "volume",
+        "distribution",
+        "schema",
+        "freshness",
+    } <= finding_types
+    assert all(item["evidence"]["deterministic"] is True for item in findings)
+    assert all(item["evidence"]["root_cause"]["lineage"] for item in findings)
+    assert all(item["evidence"]["root_cause"]["changes"] for item in findings)
+    assert all(item["evidence"]["root_cause"]["runs"] for item in findings)
+    events = repository.list_sla_events(degraded["uid"])
+    assert {item["sla_type"] for item in events} == {
+        "freshness",
+        "quality_score",
+    }
+    assert {item["status"] for item in events} == {"violated"}
+    assert all(item["escalation_level"] >= 1 for item in events)
+
+    trend = quality.trend(PART_ASSET_UID)
+    assert [point["run_uid"] for point in trend["points"]][-1] == degraded["uid"]
+    assert trend["latest"]["score_delta"] < 0
+
+
+def test_quality_sla_recovery_is_an_explicit_event(service):
+    quality, repository, clock = service
+    template = _publish_template(quality)
+    payload = {
+        "template_uid": template["uid"],
+        "asset_uid": DEVICE_ASSET_UID,
+        "field_bindings": {
+            "identity": "device_code",
+            "category": "device_type",
+            "measure": "temperature",
+        },
+    }
+    quality.execute(
+        {
+            **payload,
+            "batch_key": "stale",
+            "source_observed_at": "2026-07-31T07:00:00+00:00",
+            "records": [
+                {
+                    "device_code": None,
+                    "device_type": "pump",
+                    "temperature": 20,
+                }
+            ],
+        },
+        actor_uid=USER_UID,
+    )
+    clock["value"] += timedelta(minutes=10)
+    recovered = quality.execute(
+        {
+            **payload,
+            "batch_key": "recovered",
+            "source_observed_at": "2026-07-31T10:05:00+00:00",
+            "records": [
+                {
+                    "device_code": "DEV-001",
+                    "device_type": "pump",
+                    "temperature": 20,
+                }
+            ],
+        },
+        actor_uid=USER_UID,
+    )
+
+    events = repository.list_sla_events(recovered["uid"])
+    assert {item["status"] for item in events} == {"recovered"}

+ 188 - 0
tests/core/data_rules/test_quality_operations_api.py

@@ -0,0 +1,188 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+import pytest
+
+TEMPLATE_UID = "01900000-0000-7000-8000-000000004501"
+RUN_UID = "01900000-0000-7000-8000-000000004502"
+ASSET_UID = "01900000-0000-7000-8000-000000004503"
+
+
+class FakeQualityOperationsService:
+    def __init__(self):
+        now = datetime(2026, 7, 31, 12, 0, tzinfo=UTC).isoformat()
+        self.template = {
+            "uid": TEMPLATE_UID,
+            "code": "GENERIC_ASSET_QUALITY",
+            "status": "published",
+            "current_version": 1,
+            "active_version_uid": "01900000-0000-7000-8000-000000004504",
+        }
+        self.run = {
+            "uid": RUN_UID,
+            "asset_uid": ASSET_UID,
+            "template_uid": TEMPLATE_UID,
+            "business_domain_uid": "spare_parts",
+            "row_count": 10,
+            "score": 88,
+            "finding_count": 1,
+            "deterministic": True,
+            "created_at": now,
+        }
+        self.actions = []
+
+    def list_templates(self):
+        self.actions.append(("list_templates",))
+        return [self.template]
+
+    def create_template(self, payload, *, actor_uid):
+        self.actions.append(("create_template", payload, actor_uid))
+        return self.template
+
+    def revise_template(
+        self,
+        uid,
+        definition,
+        *,
+        expected_version,
+        actor_uid,
+    ):
+        self.actions.append(
+            (
+                "revise_template",
+                uid,
+                definition,
+                expected_version,
+                actor_uid,
+            )
+        )
+        return self.template
+
+    def publish_template(self, uid, *, expected_version, actor_uid):
+        self.actions.append(
+            ("publish_template", uid, expected_version, actor_uid)
+        )
+        return self.template
+
+    def execute(self, payload, *, actor_uid):
+        self.actions.append(("execute", payload, actor_uid))
+        return self.run
+
+    def list_runs(self, asset_uid=None):
+        self.actions.append(("list_runs", asset_uid))
+        return [self.run]
+
+    def get_run(self, uid):
+        self.actions.append(("get_run", uid))
+        return {**self.run, "findings": [], "sla_events": [], "metrics": []}
+
+    def trend(self, asset_uid):
+        self.actions.append(("trend", asset_uid))
+        return {
+            "asset_uid": asset_uid,
+            "points": [{"run_uid": RUN_UID, "score": 88}],
+            "latest": {"score_delta": -2},
+        }
+
+
+@pytest.fixture()
+def client(monkeypatch):
+    from flask import request
+
+    from app import create_app
+    from app.api.data_rules import quality_routes
+    from app.core.system import permissions
+
+    service = FakeQualityOperationsService()
+
+    def identity():
+        role = request.headers.get("Authorization", "").removeprefix("Bearer ")
+        if role not in {"viewer", "editor", "admin"}:
+            return None
+        return {
+            "id": f"01900000-0000-7000-8000-0000000045{role == 'admin'}0",
+            "roles": [role],
+        }
+
+    monkeypatch.setattr(permissions, "authenticate_request", identity)
+    monkeypatch.setattr(
+        quality_routes,
+        "get_quality_operations_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_templates_runs_details_and_trend(client):
+    http, service = client
+    responses = [
+        http.get("/api/rules/quality-operations/templates", headers=_headers("viewer")),
+        http.get("/api/rules/quality-operations/runs", headers=_headers("viewer")),
+        http.get(
+            f"/api/rules/quality-operations/runs/{RUN_UID}",
+            headers=_headers("viewer"),
+        ),
+        http.get(
+            f"/api/rules/quality-operations/assets/{ASSET_UID}/trend",
+            headers=_headers("viewer"),
+        ),
+    ]
+
+    assert [item.status_code for item in responses] == [200, 200, 200, 200]
+    assert responses[2].get_json()["data"]["deterministic"] is True
+    assert responses[3].get_json()["data"]["latest"]["score_delta"] == -2
+    assert ("list_templates",) in service.actions
+
+
+def test_editor_creates_revises_and_executes_but_cannot_publish(client):
+    http, service = client
+    created = http.post(
+        "/api/rules/quality-operations/templates",
+        json={"code": "GENERIC_ASSET_QUALITY"},
+        headers=_headers("editor"),
+    )
+    revised = http.post(
+        f"/api/rules/quality-operations/templates/{TEMPLATE_UID}/revisions",
+        json={"definition": {"schema_version": "1.0"}, "expected_version": 1},
+        headers=_headers("editor"),
+    )
+    executed = http.post(
+        "/api/rules/quality-operations/execute",
+        json={"template_uid": TEMPLATE_UID, "asset_uid": ASSET_UID},
+        headers=_headers("editor"),
+    )
+    publish_denied = http.post(
+        f"/api/rules/quality-operations/templates/{TEMPLATE_UID}/publish",
+        json={"expected_version": 1},
+        headers=_headers("editor"),
+    )
+
+    assert [created.status_code, revised.status_code, executed.status_code] == [
+        201,
+        201,
+        201,
+    ]
+    assert executed.get_json()["data"]["deterministic"] is True
+    assert publish_denied.status_code == 403
+    assert any(item[0] == "execute" for item in service.actions)
+
+
+def test_admin_publishes_and_unauthenticated_request_is_rejected(client):
+    http, service = client
+    published = http.post(
+        f"/api/rules/quality-operations/templates/{TEMPLATE_UID}/publish",
+        json={"expected_version": 1},
+        headers=_headers("admin"),
+    )
+    rejected = http.get("/api/rules/quality-operations/templates")
+
+    assert published.status_code == 200
+    assert rejected.status_code == 401
+    assert any(item[0] == "publish_template" for item in service.actions)

+ 59 - 0
tests/core/data_rules/test_quality_operations_frontend_contract.py

@@ -0,0 +1,59 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+
+
+def test_cross_domain_quality_operations_console_is_user_visible():
+    api = (ROOT / "frontend/src/api/qualityOperations.js").read_text()
+    page = (
+        ROOT
+        / "frontend/src/views/dataGovernance/development/qualityOperations.vue"
+    ).read_text()
+    model = (
+        ROOT
+        / "frontend/src/views/dataGovernance/development/qualityOperationsModel.js"
+    ).read_text()
+    routes = (ROOT / "frontend/src/router/routes.js").read_text()
+    center = (
+        ROOT / "frontend/src/views/dataGovernance/development/index.vue"
+    ).read_text()
+
+    for operation in (
+        "getQualityTemplates",
+        "createQualityTemplate",
+        "reviseQualityTemplate",
+        "publishQualityTemplate",
+        "executeQualityProfile",
+        "getQualityRuns",
+        "getQualityRun",
+        "getQualityTrend",
+    ):
+        assert operation in api
+        assert operation in page
+
+    for capability in (
+        "通用质量运营",
+        "跨域质量模板",
+        "字段角色绑定",
+        "完整率",
+        "唯一性",
+        "分布",
+        "空值",
+        "模式",
+        "样例",
+        "同比",
+        "环比",
+        "异常值",
+        "新鲜度",
+        "质量 SLA",
+        "违约与升级",
+        "血缘、变更、运行和责任证据",
+        "确定性结果",
+    ):
+        assert capability in page
+
+    assert "rules:execute" in model
+    assert "rules:publish" in model
+    assert "/data-governance/development/quality-operations" in routes
+    assert "dataGovernance/development/qualityOperations" in routes
+    assert "/data-governance/development/quality-operations" in center

+ 433 - 0
tests/integration/test_quality_operations_postgres.py

@@ -0,0 +1,433 @@
+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 _definition():
+    return {
+        "schema_version": "1.0",
+        "field_roles": {
+            "identity": {"required": True, "checks": ["unique", "pattern"]},
+            "category": {"required": True, "checks": ["distribution"]},
+            "measure": {"required": False, "checks": ["outlier"]},
+        },
+        "thresholds": {
+            "completeness_min": 0.95,
+            "uniqueness_min": 1,
+            "pattern": "^[A-Z]{2,5}-[0-9]{3}$",
+            "volume_change_max_ratio": 0.25,
+            "distribution_drift_max": 0.2,
+            "freshness_max_seconds": 3600,
+            "quality_score_min": 80,
+        },
+        "sample_limit": 5,
+    }
+
+
+def test_same_template_persists_two_domains_and_root_cause_evidence(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.data_rules.quality_operations import QualityOperationsService
+    from app.core.data_rules.quality_repository import (
+        SqlAlchemyQualityOperationsRepository,
+    )
+
+    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())
+    metadata_run_uid = str(uuid.uuid4())
+    device_asset_uid = str(uuid.uuid4())
+    parts_asset_uid = str(uuid.uuid4())
+    template_uid = None
+    run_uids = []
+    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-wp04-integration-hash', 'active'
+                        )
+                        """
+                    ),
+                    {
+                        "uid": uid,
+                        "username": f"wp04-{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"WP04 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', '{}'::jsonb,
+                        '{}'::jsonb, CAST(:owner_uid AS uuid), TRUE, 1,
+                        CAST(:created_by AS uuid)
+                    )
+                    """
+                ),
+                {
+                    "uid": plan_uid,
+                    "source_uid": source_uid,
+                    "name": f"WP04 plan {plan_uid[:8]}",
+                    "owner_uid": owner_uid,
+                    "created_by": actor_uid,
+                },
+            )
+            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,
+                        actor_uid, started_at, finished_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:plan_uid AS uuid),
+                        'wp04-metadata', 'completed', 1, '{}'::jsonb,
+                        '{}'::jsonb, :snapshot_hash, '{}'::jsonb,
+                        CAST(:actor_uid AS uuid), :started_at, :finished_at
+                    )
+                    """
+                ),
+                {
+                    "uid": metadata_run_uid,
+                    "plan_uid": plan_uid,
+                    "snapshot_hash": "a" * 64,
+                    "actor_uid": actor_uid,
+                    "started_at": now - timedelta(minutes=5),
+                    "finished_at": now,
+                },
+            )
+            for asset_uid, namespace, name, domain in (
+                (
+                    device_asset_uid,
+                    "maintenance",
+                    "device_ledger",
+                    "device",
+                ),
+                (
+                    parts_asset_uid,
+                    "supply",
+                    "spare_parts",
+                    "spare_parts",
+                ),
+            ):
+                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, :namespace, :name, '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}:{namespace}.{name}",
+                        "namespace": namespace,
+                        "name": name,
+                        "content_hash": "b" * 64,
+                        "snapshot": (
+                            '{"business_domain_uid": "' + domain + '"}'
+                        ),
+                        "last_run_uid": metadata_run_uid,
+                    },
+                )
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_changes (
+                        uid, run_uid, asset_uid, asset_key, field_name,
+                        change_type, before_state, after_state, status
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:run_uid AS uuid),
+                        CAST(:asset_uid AS uuid), :asset_key, 'part_category',
+                        'field_changed', '{}'::jsonb, '{}'::jsonb, 'accepted'
+                    )
+                    """
+                ),
+                {
+                    "uid": str(uuid.uuid4()),
+                    "run_uid": metadata_run_uid,
+                    "asset_uid": parts_asset_uid,
+                    "asset_key": f"{source_uid}:supply.spare_parts",
+                },
+            )
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_lineage (
+                        uid, run_uid, parse_status, source_asset, source_field,
+                        target_asset, target_field, relation_type, evidence
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:run_uid AS uuid), 'resolved',
+                        'erp.parts', 'part_code', 'supply.spare_parts',
+                        'part_code', 'derived_from', '{}'::jsonb
+                    )
+                    """
+                ),
+                {"uid": str(uuid.uuid4()), "run_uid": metadata_run_uid},
+            )
+            db.session.commit()
+
+            repository = SqlAlchemyQualityOperationsRepository(db.session)
+            quality = QualityOperationsService(
+                repository,
+                publish_authorizer=lambda _actor_uid: None,
+                commit=db.session.commit,
+                rollback=db.session.rollback,
+            )
+            draft = quality.create_template(
+                {
+                    "code": f"WP04_GENERIC_{source_uid[:8].upper()}",
+                    "name": "WP04 跨域通用质量模板",
+                    "owner_uid": owner_uid,
+                    "definition": _definition(),
+                },
+                actor_uid=actor_uid,
+            )
+            template_uid = draft["uid"]
+            published = quality.publish_template(
+                template_uid,
+                expected_version=1,
+                actor_uid=actor_uid,
+            )
+
+            device = quality.execute(
+                {
+                    "template_uid": template_uid,
+                    "asset_uid": device_asset_uid,
+                    "batch_key": "device-baseline",
+                    "field_bindings": {
+                        "identity": "device_code",
+                        "category": "device_type",
+                        "measure": "temperature",
+                    },
+                    "source_observed_at": (
+                        now - timedelta(minutes=10)
+                    ).isoformat(),
+                    "records": [
+                        {
+                            "device_code": "DEV-001",
+                            "device_type": "pump",
+                            "temperature": 22,
+                        },
+                        {
+                            "device_code": "DEV-002",
+                            "device_type": "fan",
+                            "temperature": 23,
+                        },
+                    ],
+                },
+                actor_uid=actor_uid,
+            )
+            run_uids.append(device["uid"])
+            parts = quality.execute(
+                {
+                    "template_uid": template_uid,
+                    "asset_uid": parts_asset_uid,
+                    "batch_key": "parts-degraded",
+                    "field_bindings": {
+                        "identity": "part_code",
+                        "category": "part_category",
+                        "measure": "stock_quantity",
+                    },
+                    "source_observed_at": (
+                        now - timedelta(hours=2)
+                    ).isoformat(),
+                    "records": [
+                        {
+                            "part_code": "PT-001",
+                            "part_category": "seal",
+                            "stock_quantity": 1,
+                        },
+                        {
+                            "part_code": "PT-001",
+                            "part_category": None,
+                            "stock_quantity": 2,
+                        },
+                        {
+                            "part_code": None,
+                            "part_category": "seal",
+                            "stock_quantity": 3,
+                        },
+                        {
+                            "part_code": "bad",
+                            "part_category": "seal",
+                            "stock_quantity": 5000,
+                        },
+                    ],
+                },
+                actor_uid=actor_uid,
+            )
+            run_uids.append(parts["uid"])
+            detail = quality.get_run(parts["uid"])
+
+            assert published["status"] == "published"
+            assert device["template_version_uid"] == parts["template_version_uid"]
+            assert {
+                device["business_domain_uid"],
+                parts["business_domain_uid"],
+            } == {"device", "spare_parts"}
+            assert detail["metrics"]
+            assert {
+                item["finding_type"] for item in detail["findings"]
+            } >= {
+                "completeness",
+                "uniqueness",
+                "pattern",
+                "duplicate",
+                "outlier",
+                "freshness",
+            }
+            assert all(
+                item["evidence"]["root_cause"]["lineage"]
+                for item in detail["findings"]
+            )
+            assert all(
+                item["evidence"]["root_cause"]["changes"]
+                for item in detail["findings"]
+            )
+            assert all(
+                item["evidence"]["root_cause"]["runs"]
+                for item in detail["findings"]
+            )
+            assert all(
+                item["evidence"]["root_cause"]["responsibility"]["owner_uid"]
+                == owner_uid
+                for item in detail["findings"]
+            )
+            assert {
+                item["sla_type"] for item in detail["sla_events"]
+            } == {"freshness", "quality_score"}
+    finally:
+        with app.app_context():
+            if run_uids:
+                for table in (
+                    "quality_sla_events",
+                    "quality_findings",
+                    "quality_profile_metrics",
+                ):
+                    db.session.execute(
+                        text(
+                            f"DELETE FROM public.{table} "
+                            "WHERE run_uid = ANY(CAST(:uids AS uuid[]))"
+                        ),
+                        {"uids": run_uids},
+                    )
+                db.session.execute(
+                    text(
+                        """
+                        DELETE FROM public.quality_profile_runs
+                        WHERE uid = ANY(CAST(:uids AS uuid[]))
+                        """
+                    ),
+                    {"uids": run_uids},
+                )
+            if template_uid:
+                db.session.execute(
+                    text(
+                        """
+                        UPDATE public.quality_templates
+                        SET active_version_uid = NULL
+                        WHERE uid = CAST(:uid AS uuid)
+                        """
+                    ),
+                    {"uid": template_uid},
+                )
+                db.session.execute(
+                    text(
+                        """
+                        DELETE FROM public.quality_template_versions
+                        WHERE template_uid = CAST(:uid AS uuid)
+                        """
+                    ),
+                    {"uid": template_uid},
+                )
+                db.session.execute(
+                    text(
+                        """
+                        DELETE FROM public.quality_templates
+                        WHERE uid = CAST(:uid AS uuid)
+                        """
+                    ),
+                    {"uid": template_uid},
+                )
+            db.session.execute(
+                text(
+                    """
+                    DELETE FROM public.active_metadata_lineage
+                    WHERE run_uid = CAST(:uid AS uuid);
+                    DELETE FROM public.active_metadata_changes
+                    WHERE run_uid = CAST(:uid AS uuid);
+                    DELETE FROM public.active_metadata_assets
+                    WHERE last_run_uid = CAST(:uid AS uuid);
+                    DELETE FROM public.active_metadata_runs
+                    WHERE uid = CAST(: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)
+                    );
+                    """
+                ),
+                {
+                    "uid": metadata_run_uid,
+                    "plan_uid": plan_uid,
+                    "source_uid": source_uid,
+                    "actor_uid": actor_uid,
+                    "owner_uid": owner_uid,
+                },
+            )
+            db.session.commit()

+ 2 - 5
tests/test_architecture_artifacts.py

@@ -24,11 +24,8 @@ def _route_count() -> int:
                     and function.value.id == "bp"
                     and (
                         function.attr == "route"
-                        or (
-                            route_file.parent.name == "knowledge_base"
-                            and function.attr
-                            in {"get", "post", "put", "patch", "delete"}
-                        )
+                        or function.attr
+                        in {"get", "post", "put", "patch", "delete"}
                     )
                 ):
                     count += 1

Some files were not shown because too many files changed in this diff