Browse Source

feat: add cross-domain semantic governance

马小龙 3 weeks ago
parent
commit
a2b9fe245e
44 changed files with 6745 additions and 46 deletions
  1. 1 0
      app/api/data_development/__init__.py
  2. 245 0
      app/api/data_development/semantic_routes.py
  3. 34 7
      app/commands/process_outbox.py
  4. 770 0
      app/core/data_research/semantic_governance.py
  5. 279 0
      app/core/data_research/semantic_knowledge.py
  6. 516 0
      app/core/data_research/semantic_repository.py
  7. 38 1
      app/core/data_rules/repository.py
  8. 17 4
      app/core/events/outbox.py
  9. 24 2
      app/core/knowledge/point_builder.py
  10. 15 0
      app/core/system/permissions.py
  11. 14 0
      app/models/__init__.py
  12. 102 0
      app/models/semantic_governance.py
  13. 6 1
      app/services/embedding/qwen.py
  14. 1 0
      deployment/app/api/data_development/__init__.py
  15. 245 0
      deployment/app/api/data_development/semantic_routes.py
  16. 34 7
      deployment/app/commands/process_outbox.py
  17. 770 0
      deployment/app/core/data_research/semantic_governance.py
  18. 279 0
      deployment/app/core/data_research/semantic_knowledge.py
  19. 516 0
      deployment/app/core/data_research/semantic_repository.py
  20. 38 1
      deployment/app/core/data_rules/repository.py
  21. 17 4
      deployment/app/core/events/outbox.py
  22. 24 2
      deployment/app/core/knowledge/point_builder.py
  23. 15 0
      deployment/app/core/system/permissions.py
  24. 14 0
      deployment/app/models/__init__.py
  25. 102 0
      deployment/app/models/semantic_governance.py
  26. 6 1
      deployment/app/services/embedding/qwen.py
  27. 5 5
      docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md
  28. 7 7
      docs/FUNCTION_MODULE_CENSUS_20260726.md
  29. 28 0
      docs/architecture/DATA_MODEL.md
  30. 368 1
      docs/architecture/OPENAPI.yaml
  31. 68 0
      docs/validation/P2_WP03_SEMANTIC_GOVERNANCE_EVIDENCE.md
  32. 54 0
      frontend/src/api/dataDevelopment.js
  33. 16 0
      frontend/src/router/routes.js
  34. 4 0
      frontend/src/views/dataGovernance/dataStandard/index.vue
  35. 348 0
      frontend/src/views/dataGovernance/dataStandard/semanticGovernance.vue
  36. 162 0
      migrations/versions/20260731_390_semantic_governance.py
  37. 456 0
      tests/data_research/test_semantic_governance.py
  38. 176 0
      tests/data_research/test_semantic_governance_api.py
  39. 58 0
      tests/data_research/test_semantic_governance_frontend_contract.py
  40. 158 0
      tests/data_research/test_semantic_knowledge_sync.py
  41. 553 0
      tests/integration/test_semantic_governance_postgres.py
  42. 84 0
      tests/knowledge/test_point_builder.py
  43. 40 3
      tests/test_outbox.py
  44. 38 0
      tests/test_phase2_wp03_migration_contract.py

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

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

+ 245 - 0
app/api/data_development/semantic_routes.py

@@ -0,0 +1,245 @@
+"""HTTP boundary for governed terms, code sets, metrics and field mappings."""
+
+from __future__ import annotations
+
+import io
+
+from flask import g, jsonify, request, send_file
+
+from app import db
+from app.api.data_development import bp
+from app.models.result import failed, success
+
+
+def get_semantic_governance_service():
+    from app.core.data_research.semantic_governance import (
+        SemanticGovernanceService,
+    )
+    from app.core.data_research.semantic_repository import (
+        SqlAlchemySemanticGovernanceRepository,
+    )
+    from app.core.events.outbox import enqueue_outbox
+
+    return SemanticGovernanceService(
+        SqlAlchemySemanticGovernanceRepository(db.session),
+        outbox_enqueue=lambda **event: enqueue_outbox(db.session, **event),
+    )
+
+
+def _actor_uid():
+    identity = getattr(g, "current_user", {}) or {}
+    return identity.get("id") or identity.get("sub")
+
+
+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.route("/semantic-assets", methods=["GET"])
+def list_semantic_assets():
+    try:
+        records = get_semantic_governance_service().list(
+            asset_kind=request.args.get("asset_kind"),
+            status=request.args.get("status"),
+        )
+        return jsonify(success(records)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets", methods=["POST"])
+def create_semantic_asset():
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().create_draft(
+            payload.get("asset_kind"),
+            {key: value for key, value in payload.items() if key != "asset_kind"},
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>", methods=["GET"])
+def get_semantic_asset(asset_uid):
+    try:
+        return jsonify(
+            success(get_semantic_governance_service().get(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/versions", methods=["GET"])
+def list_semantic_asset_versions(asset_uid):
+    try:
+        return jsonify(
+            success(
+                get_semantic_governance_service().list_versions(asset_uid)
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/reviews", methods=["GET"])
+def list_semantic_asset_reviews(asset_uid):
+    try:
+        return jsonify(
+            success(get_semantic_governance_service().list_reviews(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/audits", methods=["GET"])
+def list_semantic_asset_audits(asset_uid):
+    try:
+        return jsonify(
+            success(get_semantic_governance_service().list_audits(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/revisions", methods=["POST"])
+def revise_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().revise(
+            asset_uid,
+            payload.get("definition") or {},
+            expected_version=int(payload.get("expected_version")),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/submit", methods=["POST"])
+def submit_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().submit(
+            asset_uid,
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/review", methods=["POST"])
+def review_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().review(
+            asset_uid,
+            expected_version=int(payload.get("expected_version")),
+            decision=payload.get("decision"),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/publish", methods=["POST"])
+def publish_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().publish(
+            asset_uid,
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/rollback", methods=["POST"])
+def rollback_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().rollback(
+            asset_uid,
+            target_version=int(payload.get("target_version")),
+            expected_version=int(payload.get("expected_version")),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-field-mappings", methods=["GET"])
+def list_semantic_field_mappings():
+    try:
+        records = get_semantic_governance_service().list_field_mappings(
+            asset_uid=request.args.get("asset_uid"),
+            data_element_uid=request.args.get("data_element_uid"),
+        )
+        return jsonify(success(records)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-field-mappings", methods=["POST"])
+def create_semantic_field_mapping():
+    try:
+        record = get_semantic_governance_service().map_physical_field(
+            request.get_json(silent=True) or {},
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/export", methods=["GET"])
+def export_semantic_assets():
+    from app.core.data_research.semantic_governance import SemanticExchange
+
+    export_format = str(request.args.get("format") or "json").lower()
+    try:
+        assets = get_semantic_governance_service().list(status="published")
+        exchange = SemanticExchange()
+        if export_format == "rdf":
+            content = exchange.export_rdf(assets)
+            filename = "semantic-governance.rdf"
+            mimetype = "application/rdf+xml"
+        elif export_format == "json":
+            content = exchange.export_json(assets)
+            filename = "semantic-governance.json"
+            mimetype = "application/json"
+        else:
+            raise ValueError("semantic export format is unsupported")
+        return send_file(
+            io.BytesIO(content),
+            mimetype=mimetype,
+            as_attachment=True,
+            download_name=filename,
+        )
+    except Exception as error:
+        return _error(error)

+ 34 - 7
app/commands/process_outbox.py

@@ -2,7 +2,8 @@ from __future__ import annotations
 
 import argparse
 import logging
-from typing import Any, Callable
+from collections.abc import Callable
+from typing import Any
 
 from app import create_app, db
 from app.core.events.consumer import dispatch_event
@@ -13,10 +14,34 @@ from app.core.events.outbox import (
     reset_stale_processing,
 )
 
-
 logger = logging.getLogger(__name__)
 
 
+def _missing_handler(event_type):
+    def handler(_payload):
+        raise ValueError(f"no handler registered for {event_type}")
+
+    return handler
+
+
+def build_governance_knowledge_handlers(app, session):
+    from app.core.data_research.semantic_knowledge import (
+        build_governance_knowledge_handlers as build_handlers,
+    )
+
+    return build_handlers(app, session)
+
+
+def build_default_handlers(app, session):
+    handlers = build_governance_knowledge_handlers(app, session)
+    if not handlers:
+        logger.warning(
+            "Governance knowledge sync is not configured; "
+            "publication events will remain pending"
+        )
+    return handlers
+
+
 def process_available_events(
     session: Any,
     handlers: dict[str, Callable[[dict[str, Any]], None]],
@@ -29,13 +54,15 @@ def process_available_events(
         logger.info("No outbox handlers registered; leaving events pending")
         return 0
 
-    events = claim_outbox(session, limit=batch_size)
+    events = claim_outbox(
+        session,
+        limit=batch_size,
+        event_types=sorted(handlers),
+    )
     for event in events:
         handler = handlers.get(event.event_type)
         if handler is None:
-            handler = lambda _payload: (_ for _ in ()).throw(
-                ValueError(f"no handler registered for {event.event_type}")
-            )
+            handler = _missing_handler(event.event_type)
         result = dispatch_event(
             event,
             handler,
@@ -65,7 +92,7 @@ def main() -> None:
         )
         processed = process_available_events(
             db.session,
-            handlers={},
+            handlers=build_default_handlers(app, db.session),
             batch_size=args.batch_size,
         )
         logger.info("Processed %s outbox events", processed)

+ 770 - 0
app/core/data_research/semantic_governance.py

@@ -0,0 +1,770 @@
+"""Generic business terms, code sets, metrics and physical-field mappings."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import uuid
+import xml.etree.ElementTree as ET
+from copy import deepcopy
+from datetime import UTC, datetime
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+
+ASSET_KINDS = frozenset({"business_term", "code_set", "metric"})
+ASSET_STATUSES = frozenset(
+    {"draft", "in_review", "approved", "published", "superseded", "retired"}
+)
+SECRET_MARKERS = (
+    "password",
+    "secret",
+    "token",
+    "credential",
+    "api_key",
+    "private_key",
+    "authorization",
+    "connection_string",
+)
+ANALYSIS_EXECUTION_FIELDS = frozenset(
+    {"execution_sql", "sql", "query", "script", "executable", "runtime"}
+)
+RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+DATAOPS_NS = "https://dataops.local/semantic#"
+
+
+def _canonical(value: Any) -> str:
+    return json.dumps(
+        value,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    )
+
+
+def _hash(value: Any) -> str:
+    return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (TypeError, ValueError, AttributeError) as exc:
+        raise ValueError(f"{field} must be a UUID") from exc
+
+
+def _text(value: Any, field: str, maximum: int = 500) -> str:
+    result = str(value or "").strip()
+    if not result or len(result) > maximum:
+        raise ValueError(f"{field} must be between 1 and {maximum} characters")
+    return result
+
+
+def _optional_text(value: Any, field: str, maximum: int = 500) -> str | None:
+    if value is None:
+        return None
+    return _text(value, field, maximum)
+
+
+def _reject_secrets(value: Any, path: str = "payload") -> None:
+    if isinstance(value, dict):
+        for key, nested in value.items():
+            normalized = str(key).casefold().replace("-", "_")
+            if any(marker in normalized for marker in SECRET_MARKERS):
+                raise ValueError(f"secret-bearing field is not allowed at {path}.{key}")
+            _reject_secrets(nested, f"{path}.{key}")
+    elif isinstance(value, list):
+        for index, nested in enumerate(value):
+            _reject_secrets(nested, f"{path}[{index}]")
+
+
+def _uuid_list(value: Any, field: str, maximum: int = 500) -> list[str]:
+    if value is None:
+        return []
+    if not isinstance(value, list) or len(value) > maximum:
+        raise ValueError(f"{field} must be a bounded list")
+    result = [_uuid(item, field) for item in value]
+    if len(set(result)) != len(result):
+        raise ValueError(f"{field} contains duplicates")
+    return result
+
+
+def _aliases(value: Any) -> list[str]:
+    if value is None:
+        return []
+    if not isinstance(value, list) or len(value) > 100:
+        raise ValueError("aliases must be a bounded list")
+    result = [_text(item, "alias", 200) for item in value]
+    if len(set(result)) != len(result):
+        raise ValueError("aliases contains duplicates")
+    return sorted(result)
+
+
+def _code_references(value: Any) -> list[dict[str, str]]:
+    if value is None:
+        return []
+    if not isinstance(value, list) or len(value) > 500:
+        raise ValueError("code_references must be a bounded list")
+    result = []
+    seen = set()
+    for item in value:
+        if not isinstance(item, dict):
+            raise ValueError("code reference must be an object")
+        reference = {
+            "code_set_uid": _uuid(item.get("code_set_uid"), "code_set_uid"),
+            "code": _text(item.get("code"), "code reference", 120),
+        }
+        key = (reference["code_set_uid"], reference["code"])
+        if key in seen:
+            raise ValueError("code_references contains duplicates")
+        seen.add(key)
+        result.append(reference)
+    return sorted(result, key=lambda item: (item["code_set_uid"], item["code"]))
+
+
+def _code_values(value: Any) -> list[dict[str, Any]]:
+    if not isinstance(value, list) or not value or len(value) > 10_000:
+        raise ValueError("values must contain between 1 and 10000 items")
+    result = []
+    codes = set()
+    for item in value:
+        if not isinstance(item, dict):
+            raise ValueError("code value must be an object")
+        code = _text(item.get("code"), "code value", 120)
+        if code in codes:
+            raise ValueError(f"duplicate code value {code}")
+        codes.add(code)
+        result.append(
+            {
+                "code": code,
+                "name": _text(item.get("name"), "code value name", 300),
+                "definition": _optional_text(
+                    item.get("definition"), "code value definition", 1000
+                ),
+                "parent_code": _optional_text(
+                    item.get("parent_code"), "parent_code", 120
+                ),
+                "status": str(item.get("status") or "active").strip().lower(),
+            }
+        )
+    for item in result:
+        if item["status"] not in {"active", "deprecated"}:
+            raise ValueError("code value status is unsupported")
+        if item["parent_code"] and item["parent_code"] not in codes:
+            raise ValueError(
+                f"parent_code {item['parent_code']} does not reference this code set"
+            )
+        if item["parent_code"] == item["code"]:
+            raise ValueError("parent_code cannot reference itself")
+    return result
+
+
+def _dimensions(value: Any) -> list[dict[str, str | None]]:
+    if not isinstance(value, list) or not value or len(value) > 100:
+        raise ValueError("dimensions must contain between 1 and 100 items")
+    result = []
+    seen = set()
+    for item in value:
+        if not isinstance(item, dict):
+            raise ValueError("dimension must be an object")
+        code = _text(item.get("code"), "dimension code", 120)
+        if code in seen:
+            raise ValueError(f"duplicate dimension {code}")
+        seen.add(code)
+        result.append(
+            {
+                "code": code,
+                "name": _text(item.get("name"), "dimension name", 300),
+                "definition": _optional_text(
+                    item.get("definition"), "dimension definition", 1000
+                ),
+            }
+        )
+    return result
+
+
+def _normalize_definition(asset_kind: str, payload: dict[str, Any]) -> dict[str, Any]:
+    common = {
+        "definition": _text(payload.get("definition"), "definition", 4000),
+        "related_asset_uids": _uuid_list(
+            payload.get("related_asset_uids"), "related_asset_uids"
+        ),
+        "related_data_element_uids": _uuid_list(
+            payload.get("related_data_element_uids"),
+            "related_data_element_uids",
+        ),
+        "standard_version_uids": _uuid_list(
+            payload.get("standard_version_uids"),
+            "standard_version_uids",
+        ),
+        "code_references": _code_references(payload.get("code_references")),
+    }
+    if asset_kind == "business_term":
+        common["aliases"] = _aliases(payload.get("aliases"))
+    elif asset_kind == "code_set":
+        common["values"] = _code_values(payload.get("values"))
+    elif asset_kind == "metric":
+        forbidden = sorted(set(payload) & ANALYSIS_EXECUTION_FIELDS)
+        if forbidden:
+            raise ValueError(
+                "metric is a definition only; analysis execution fields are forbidden"
+            )
+        common.update(
+            {
+                "formula": _text(payload.get("formula"), "formula", 4000),
+                "unit": _text(payload.get("unit"), "unit", 80),
+                "dimensions": _dimensions(payload.get("dimensions")),
+            }
+        )
+    return common
+
+
+def _links(asset_uid: str, version: int, definition: dict[str, Any]) -> list[dict]:
+    result = []
+    for link_type, key, target_type in (
+        ("asset", "related_asset_uids", "active_metadata_asset"),
+        ("data_element", "related_data_element_uids", "data_element"),
+        ("standard", "standard_version_uids", "data_standard_version"),
+    ):
+        result.extend(
+            {
+                "asset_uid": asset_uid,
+                "version": version,
+                "link_type": link_type,
+                "target_type": target_type,
+                "target_uid": uid,
+                "target_key": None,
+                "metadata": {},
+            }
+            for uid in definition.get(key, [])
+        )
+    result.extend(
+        {
+            "asset_uid": asset_uid,
+            "version": version,
+            "link_type": "code_value",
+            "target_type": "code_set",
+            "target_uid": item["code_set_uid"],
+            "target_key": item["code"],
+            "metadata": {},
+        }
+        for item in definition.get("code_references", [])
+    )
+    return result
+
+
+class SemanticGovernanceService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory=new_governance_uid,
+        now_factory=lambda: datetime.now(UTC),
+        outbox_enqueue=lambda **_event: None,
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.outbox_enqueue = outbox_enqueue
+
+    def _now(self) -> str:
+        return self.now_factory().isoformat()
+
+    def _audit(
+        self,
+        uid: str,
+        version: int,
+        action: str,
+        actor_uid: str,
+        detail: dict[str, Any] | None = None,
+    ) -> dict[str, Any]:
+        return {
+            "uid": self.uid_factory(),
+            "target_type": "semantic_asset",
+            "target_uid": uid,
+            "target_version": version,
+            "action": action,
+            "actor_uid": _uuid(actor_uid, "actor_uid"),
+            "detail": deepcopy(detail or {}),
+            "created_at": self._now(),
+        }
+
+    def _validate_references(self, definition: dict[str, Any]) -> None:
+        asset_uids = definition.get("related_asset_uids", [])
+        if asset_uids and not self.repository.active_assets_exist(asset_uids):
+            raise ValueError("related asset must exist in active metadata")
+        element_uids = definition.get("related_data_element_uids", [])
+        if element_uids and not self.repository.published_data_elements_exist(
+            element_uids
+        ):
+            raise ValueError("related data element must be published")
+        standard_uids = definition.get("standard_version_uids", [])
+        if standard_uids and not self.repository.published_standard_versions_exist(
+            standard_uids
+        ):
+            raise ValueError("related standard version must be published")
+        for reference in definition.get("code_references", []):
+            if not self.repository.published_code_exists(
+                reference["code_set_uid"], reference["code"]
+            ):
+                raise ValueError("published code reference was not found")
+
+    def create_draft(self, asset_kind: str, payload: Any, *, actor_uid: str):
+        if asset_kind not in ASSET_KINDS:
+            raise ValueError("semantic asset kind is unsupported")
+        if not isinstance(payload, dict):
+            raise ValueError("semantic asset payload must be an object")
+        _reject_secrets(payload)
+        code = _text(payload.get("code"), "code", 120)
+        if self.repository.get_by_code(asset_kind, code) is not None:
+            raise ValueError("semantic asset code already exists")
+        definition = _normalize_definition(asset_kind, payload)
+        self._validate_references(definition)
+        uid = self.uid_factory()
+        now = self._now()
+        asset = {
+            "uid": uid,
+            "asset_kind": asset_kind,
+            "code": code,
+            "name": _text(payload.get("name"), "name", 300),
+            "owner_uid": _uuid(payload.get("owner_uid"), "owner_uid"),
+            "business_domain_uid": _uuid(
+                payload.get("business_domain_uid"), "business_domain_uid"
+            ),
+            "status": "draft",
+            "current_version": 1,
+            "created_by": _uuid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "updated_at": now,
+        }
+        version = {
+            "uid": self.uid_factory(),
+            "asset_uid": uid,
+            "version": 1,
+            "status": "draft",
+            "content_hash": _hash(definition),
+            "definition": definition,
+            "change_reason": "initial draft",
+            "created_by": asset["created_by"],
+            "rollback_from_version": None,
+            "created_at": now,
+            "published_at": None,
+        }
+        return self.repository.save_new(
+            asset,
+            version,
+            self._audit(uid, 1, "created", actor_uid),
+            _links(uid, 1, definition),
+        )
+
+    def get(self, uid: str):
+        result = self.repository.get(_uuid(uid, "asset_uid"))
+        if result is None:
+            raise LookupError("semantic asset was not found")
+        return result
+
+    def list(self, *, asset_kind: str | None = None, status: str | None = None):
+        if asset_kind is not None and asset_kind not in ASSET_KINDS:
+            raise ValueError("semantic asset kind is unsupported")
+        if status is not None and status not in ASSET_STATUSES:
+            raise ValueError("semantic asset status is unsupported")
+        return self.repository.list(asset_kind=asset_kind, status=status)
+
+    def list_versions(self, uid: str):
+        self.get(uid)
+        return self.repository.list_versions(uid)
+
+    def get_published_version(self, uid: str, version: int):
+        asset = self.get(uid)
+        record = next(
+            (
+                item
+                for item in self.repository.list_versions(asset["uid"])
+                if int(item["version"]) == int(version)
+            ),
+            None,
+        )
+        if record is None or record["status"] not in {"published", "superseded"}:
+            raise LookupError("published semantic asset version was not found")
+        return {
+            **asset,
+            "status": record["status"],
+            "current_version": int(record["version"]),
+            "definition": deepcopy(record["definition"]),
+        }
+
+    def list_reviews(self, uid: str):
+        self.get(uid)
+        return self.repository.list_reviews(uid)
+
+    def list_audits(self, uid: str):
+        self.get(uid)
+        return self.repository.list_audits(uid)
+
+    def _version(self, asset: dict[str, Any]) -> dict[str, Any]:
+        versions = self.repository.list_versions(asset["uid"])
+        return next(
+            item
+            for item in versions
+            if int(item["version"]) == int(asset["current_version"])
+        )
+
+    def _expected(
+        self, uid: str, expected_version: int
+    ) -> tuple[dict[str, Any], dict[str, Any]]:
+        asset = self.get(uid)
+        if int(expected_version) != int(asset["current_version"]):
+            raise ValueError(
+                f"expected version {expected_version}, "
+                f"current version {asset['current_version']}"
+            )
+        return asset, self._version(asset)
+
+    def submit(
+        self,
+        uid: str,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        asset, version = self._expected(uid, expected_version)
+        if asset["status"] != "draft":
+            raise ValueError("only a draft semantic asset can be submitted")
+        asset.update({"status": "in_review", "updated_at": self._now()})
+        version["status"] = "in_review"
+        return self.repository.transition(
+            asset,
+            version,
+            self._audit(uid, asset["current_version"], "submitted", actor_uid),
+        )
+
+    def review(
+        self,
+        uid: str,
+        *,
+        expected_version: int,
+        decision: str,
+        reason: str,
+        actor_uid: str,
+    ):
+        asset, version = self._expected(uid, expected_version)
+        reviewer_uid = _uuid(actor_uid, "actor_uid")
+        if reviewer_uid == str(version["created_by"]):
+            raise ValueError("semantic asset self-review is not allowed")
+        if asset["status"] != "in_review":
+            raise ValueError("only an in-review semantic asset can be reviewed")
+        decision = str(decision or "").strip().lower()
+        if decision not in {"approve", "reject"}:
+            raise ValueError("semantic review decision is unsupported")
+        reason = _text(reason, "review reason", 1000)
+        target_status = "approved" if decision == "approve" else "draft"
+        asset.update({"status": target_status, "updated_at": self._now()})
+        version["status"] = target_status
+        review = {
+            "uid": self.uid_factory(),
+            "asset_uid": asset["uid"],
+            "version": asset["current_version"],
+            "decision": decision,
+            "reason": reason,
+            "reviewer_uid": reviewer_uid,
+            "created_at": self._now(),
+        }
+        return self.repository.transition(
+            asset,
+            version,
+            self._audit(
+                uid,
+                asset["current_version"],
+                "approved" if decision == "approve" else "rejected",
+                actor_uid,
+                {"reason": reason},
+            ),
+            review,
+        )
+
+    def publish(
+        self,
+        uid: str,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        asset, version = self._expected(uid, expected_version)
+        if asset["status"] != "approved":
+            raise ValueError("only an approved semantic asset can be published")
+        self._validate_references(version["definition"])
+        now = self._now()
+        asset.update({"status": "published", "updated_at": now})
+        version.update({"status": "published", "published_at": now})
+        published = self.repository.transition(
+            asset,
+            version,
+            self._audit(uid, asset["current_version"], "published", actor_uid),
+        )
+        self._enqueue_publication(published)
+        return published
+
+    def revise(
+        self,
+        uid: str,
+        payload: Any,
+        *,
+        expected_version: int,
+        reason: str,
+        actor_uid: str,
+    ):
+        asset, _current = self._expected(uid, expected_version)
+        if asset["status"] not in {"published", "draft"}:
+            raise ValueError("semantic asset cannot be revised in its current status")
+        if not isinstance(payload, dict):
+            raise ValueError("semantic revision payload must be an object")
+        _reject_secrets(payload)
+        merged = {
+            "code": asset["code"],
+            "name": payload.get("name") or asset["name"],
+            "owner_uid": payload.get("owner_uid") or asset["owner_uid"],
+            "business_domain_uid": (
+                payload.get("business_domain_uid") or asset["business_domain_uid"]
+            ),
+            **payload,
+        }
+        definition = _normalize_definition(asset["asset_kind"], merged)
+        self._validate_references(definition)
+        now = self._now()
+        version_number = int(asset["current_version"]) + 1
+        asset.update(
+            {
+                "name": _text(merged.get("name"), "name", 300),
+                "owner_uid": _uuid(merged.get("owner_uid"), "owner_uid"),
+                "business_domain_uid": _uuid(
+                    merged.get("business_domain_uid"), "business_domain_uid"
+                ),
+                "status": "draft",
+                "current_version": version_number,
+                "updated_at": now,
+            }
+        )
+        version = {
+            "uid": self.uid_factory(),
+            "asset_uid": asset["uid"],
+            "version": version_number,
+            "status": "draft",
+            "content_hash": _hash(definition),
+            "definition": definition,
+            "change_reason": _text(reason, "change reason", 1000),
+            "created_by": _uuid(actor_uid, "actor_uid"),
+            "rollback_from_version": None,
+            "created_at": now,
+            "published_at": None,
+        }
+        return self.repository.append_version(
+            asset,
+            version,
+            self._audit(
+                uid,
+                version_number,
+                "revised",
+                actor_uid,
+                {"reason": version["change_reason"]},
+            ),
+            _links(uid, version_number, definition),
+        )
+
+    def rollback(
+        self,
+        uid: str,
+        *,
+        target_version: int,
+        expected_version: int,
+        reason: str,
+        actor_uid: str,
+    ):
+        asset, _current = self._expected(uid, expected_version)
+        target = next(
+            (
+                item
+                for item in self.repository.list_versions(uid)
+                if int(item["version"]) == int(target_version)
+            ),
+            None,
+        )
+        if target is None or target["status"] not in {"published", "superseded"}:
+            raise ValueError("rollback target must be a published version")
+        now = self._now()
+        version_number = int(asset["current_version"]) + 1
+        definition = deepcopy(target["definition"])
+        asset.update(
+            {
+                "status": "published",
+                "current_version": version_number,
+                "updated_at": now,
+            }
+        )
+        version = {
+            "uid": self.uid_factory(),
+            "asset_uid": asset["uid"],
+            "version": version_number,
+            "status": "published",
+            "content_hash": _hash(definition),
+            "definition": definition,
+            "change_reason": _text(reason, "rollback reason", 1000),
+            "created_by": _uuid(actor_uid, "actor_uid"),
+            "rollback_from_version": int(target_version),
+            "created_at": now,
+            "published_at": now,
+        }
+        rolled_back = self.repository.append_version(
+            asset,
+            version,
+            self._audit(
+                uid,
+                version_number,
+                "rolled_back",
+                actor_uid,
+                {
+                    "target_version": int(target_version),
+                    "reason": version["change_reason"],
+                },
+            ),
+            _links(uid, version_number, definition),
+        )
+        self._enqueue_publication(rolled_back)
+        return rolled_back
+
+    def _enqueue_publication(self, asset: dict[str, Any]) -> None:
+        self.outbox_enqueue(
+            aggregate_type="semantic_asset",
+            aggregate_id=asset["uid"],
+            event_type="semantic_asset.version_published",
+            payload={
+                "uid": asset["uid"],
+                "asset_kind": asset["asset_kind"],
+                "version": asset["current_version"],
+            },
+        )
+
+    def map_physical_field(self, payload: Any, *, actor_uid: str):
+        if not isinstance(payload, dict):
+            raise ValueError("field mapping payload must be an object")
+        _reject_secrets(payload)
+        asset_uid = _uuid(payload.get("asset_uid"), "asset_uid")
+        field_name = _text(payload.get("field_name"), "field_name", 200)
+        data_element_uid = _uuid(
+            payload.get("data_element_uid"), "data_element_uid"
+        )
+        source_asset = self.repository.get_active_metadata_asset(asset_uid)
+        if source_asset is None:
+            raise ValueError("active metadata asset was not found")
+        fields = {
+            str(item.get("name"))
+            for item in source_asset.get("snapshot", {}).get("fields", [])
+        }
+        if field_name not in fields:
+            raise ValueError("physical field was not found in active metadata")
+        element = self.repository.get_data_element(data_element_uid)
+        if element is None or element.get("status") != "published":
+            raise ValueError("physical mapping requires a published data element")
+        now = self._now()
+        mapping = {
+            "uid": self.uid_factory(),
+            "asset_uid": asset_uid,
+            "field_name": field_name,
+            "data_element_uid": data_element_uid,
+            "data_element_version": int(element["current_version"]),
+            "owner_uid": _uuid(payload.get("owner_uid"), "owner_uid"),
+            "status": "published",
+            "evidence": deepcopy(payload.get("evidence") or {}),
+            "created_by": _uuid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "updated_at": now,
+        }
+        audit = {
+            "uid": self.uid_factory(),
+            "target_type": "field_mapping",
+            "target_uid": mapping["uid"],
+            "target_version": 1,
+            "action": "mapped",
+            "actor_uid": mapping["created_by"],
+            "detail": {
+                "asset_uid": asset_uid,
+                "field_name": field_name,
+                "data_element_uid": data_element_uid,
+                "data_element_version": mapping["data_element_version"],
+            },
+            "created_at": now,
+        }
+        return self.repository.save_field_mapping(mapping, audit)
+
+    def list_field_mappings(
+        self,
+        *,
+        asset_uid: str | None = None,
+        data_element_uid: str | None = None,
+    ):
+        return self.repository.list_field_mappings(
+            asset_uid=_uuid(asset_uid, "asset_uid") if asset_uid else None,
+            data_element_uid=(
+                _uuid(data_element_uid, "data_element_uid")
+                if data_element_uid
+                else None
+            ),
+        )
+
+
+def _safe(value: Any, key: str = "") -> Any:
+    normalized = key.casefold().replace("-", "_")
+    if any(marker in normalized for marker in SECRET_MARKERS):
+        return "[redacted]"
+    if isinstance(value, dict):
+        return {
+            str(item_key): _safe(item_value, str(item_key))
+            for item_key, item_value in sorted(value.items())
+        }
+    if isinstance(value, list):
+        return [_safe(item) for item in value]
+    return value
+
+
+class SemanticExchange:
+    """Deterministic JSON and controlled RDF/XML export for published semantics."""
+
+    @staticmethod
+    def _published(assets: list[dict[str, Any]]) -> list[dict[str, Any]]:
+        return [
+            _safe(item)
+            for item in assets
+            if item.get("status") == "published"
+        ]
+
+    def export_json(self, assets: list[dict[str, Any]]) -> bytes:
+        payload = {
+            "format": "dataops-semantic-governance-json-v1",
+            "assets": self._published(assets),
+        }
+        return _canonical(payload).encode("utf-8")
+
+    def export_rdf(self, assets: list[dict[str, Any]]) -> bytes:
+        ET.register_namespace("rdf", RDF_NS)
+        ET.register_namespace("dataops", DATAOPS_NS)
+        root = ET.Element(f"{{{RDF_NS}}}RDF")
+        tags = {
+            "business_term": "BusinessTerm",
+            "code_set": "CodeSet",
+            "metric": "MetricDefinition",
+        }
+        for asset in self._published(assets):
+            node = ET.SubElement(root, f"{{{DATAOPS_NS}}}{tags[asset['asset_kind']]}")
+            node.set(f"{{{RDF_NS}}}about", f"urn:dataops:semantic:{asset['uid']}")
+            for tag, value in (
+                ("code", asset["code"]),
+                ("name", asset["name"]),
+                ("version", asset["current_version"]),
+                ("definition", asset["definition"]),
+            ):
+                child = ET.SubElement(node, f"{{{DATAOPS_NS}}}{tag}")
+                child.text = (
+                    _canonical(value)
+                    if isinstance(value, (dict, list))
+                    else str(value)
+                )
+        return ET.tostring(root, encoding="utf-8", xml_declaration=True)

+ 279 - 0
app/core/data_research/semantic_knowledge.py

@@ -0,0 +1,279 @@
+"""Project published semantic assets into the canonical governance knowledge model."""
+
+from __future__ import annotations
+
+from sqlalchemy import text
+
+from app.core.knowledge.point_builder import build_knowledge_snapshot
+
+OBJECT_TYPES = {
+    "business_term": "BusinessTerm",
+    "code_set": "CodeSet",
+    "metric": "MetricDefinition",
+}
+
+
+class SemanticKnowledgeSyncHandler:
+    def __init__(self, semantic_service, knowledge_sync):
+        self.semantic_service = semantic_service
+        self.knowledge_sync = knowledge_sync
+
+    def handle(self, payload):
+        uid = str(payload.get("uid") or "")
+        kind = str(payload.get("asset_kind") or "")
+        version = int(payload.get("version") or 0)
+        if not uid or kind not in OBJECT_TYPES or version <= 0:
+            raise ValueError("semantic publication event is invalid")
+        asset = self.semantic_service.get_published_version(uid, version)
+        if asset["asset_kind"] != kind:
+            raise ValueError("semantic publication kind does not match")
+        source = {
+            **asset["definition"],
+            "uid": asset["uid"],
+            "version": asset["current_version"],
+            "name": asset.get("name") or asset.get("code") or asset["uid"],
+            "owner": asset.get("owner_uid"),
+            "business_domain_uid": asset.get("business_domain_uid"),
+        }
+        snapshot = build_knowledge_snapshot(OBJECT_TYPES[kind], source)
+        return self.knowledge_sync.sync(snapshot)
+
+
+class PublishedGovernanceKnowledgeHandler:
+    def __init__(
+        self,
+        *,
+        knowledge_sync,
+        standard_loader,
+        ontology_loader,
+    ):
+        self.knowledge_sync = knowledge_sync
+        self.standard_loader = standard_loader
+        self.ontology_loader = ontology_loader
+
+    def _sync(self, object_type, record):
+        if not record or record.get("status") != "published":
+            raise ValueError(
+                f"published {object_type.casefold()} version was not found"
+            )
+        return self.knowledge_sync.sync(
+            build_knowledge_snapshot(object_type, record)
+        )
+
+    def handle_standard(self, payload):
+        version_uid = str(payload.get("version_uid") or "")
+        if not version_uid:
+            raise ValueError("standard publication event is invalid")
+        return self._sync(
+            "DataStandard",
+            self.standard_loader(version_uid),
+        )
+
+    def handle_ontology(self, payload):
+        version_uid = str(payload.get("version_uid") or "")
+        if not version_uid:
+            raise ValueError("ontology publication event is invalid")
+        return self._sync(
+            "Ontology",
+            self.ontology_loader(version_uid),
+        )
+
+
+def _build_knowledge_sync(app, session):
+    if not app.config.get("KNOWLEDGE_ENABLED", True):
+        return None
+    api_key = app.config.get("QWEN_EMBEDDING_API_KEY", "")
+    base_url = app.config.get("QWEN_EMBEDDING_BASE_URL", "")
+    if not api_key or not base_url:
+        return None
+    profile = (
+        session.execute(
+            text(
+                """
+                SELECT id::text, provider, model, dimension
+                FROM public.knowledge_embedding_profiles
+                WHERE status = 'active'
+                """
+            )
+        )
+        .mappings()
+        .one_or_none()
+    )
+    if profile is None:
+        return None
+    model = app.config["QWEN_EMBEDDING_MODEL"]
+    dimension = int(app.config["QWEN_EMBEDDING_DIMENSION"])
+    if (
+        profile["provider"] != "qwen"
+        or profile["model"] != model
+        or int(profile["dimension"]) != dimension
+    ):
+        raise RuntimeError(
+            "active knowledge embedding profile does not match Qwen configuration"
+        )
+
+    from app.core.knowledge.repository import SqlKnowledgeRepository
+    from app.core.knowledge.sync import KnowledgeSyncService
+    from app.services.embedding.qwen import QwenEmbeddingClient
+
+    embedder = QwenEmbeddingClient(
+        api_key=api_key,
+        base_url=base_url,
+        model=model,
+        dimension=dimension,
+    )
+    return KnowledgeSyncService(
+        repository=SqlKnowledgeRepository(
+            session,
+            embedding_profile_id=profile["id"],
+            embedding_profile_key=embedder.profile_key,
+            generation=1,
+            workspace="dataops-governance-g1",
+        ),
+        embedder=embedder,
+    )
+
+
+def _load_standard(session, version_uid):
+    row = (
+        session.execute(
+            text(
+                """
+                SELECT version.id::text AS uid,
+                       version.standard_uid::text AS standard_uid,
+                       version.version_no AS version,
+                       version.status,
+                       standard.name,
+                       standard.owner_uid::text AS owner,
+                       version.standard_spec,
+                       version.scope
+                FROM public.data_standard_versions version
+                JOIN public.data_standards standard
+                  ON standard.standard_uid = version.standard_uid
+                WHERE version.id = CAST(:version_uid AS uuid)
+                """
+            ),
+            {"version_uid": version_uid},
+        )
+        .mappings()
+        .one_or_none()
+    )
+    if row is None:
+        return None
+    spec = dict(row["standard_spec"] or {})
+    scope = dict(row["scope"] or {})
+    return {
+        "uid": str(row["standard_uid"]),
+        "version_uid": str(row["uid"]),
+        "version": int(row["version"]),
+        "status": row["status"],
+        "name": row["name"],
+        "definition": spec.get("description") or row["name"],
+        "owner": row["owner"],
+        "business_domain_uid": scope.get("business_domain_uid"),
+        "rules": [
+            {
+                "uid": item["id"],
+                "name": item["id"],
+                "definition": item["description"],
+            }
+            for item in spec.get("clauses", [])
+        ],
+    }
+
+
+def _load_ontology(session, version_uid):
+    row = (
+        session.execute(
+            text(
+                """
+                SELECT version.uid::text AS version_uid,
+                       ontology.uid::text AS uid,
+                       version.version,
+                       version.status,
+                       ontology.name,
+                       ontology.code,
+                       ontology.owner_uid AS owner,
+                       version.graph_document,
+                       (
+                           SELECT link.domain_uid::text
+                           FROM public.ontology_domain_links link
+                           WHERE link.ontology_uid = ontology.uid
+                           ORDER BY CASE link.role
+                               WHEN 'owner' THEN 0 ELSE 1 END,
+                               link.domain_uid
+                           LIMIT 1
+                       ) AS business_domain_uid
+                FROM public.ontology_versions version
+                JOIN public.ontologies ontology
+                  ON ontology.uid = version.ontology_uid
+                WHERE version.uid = CAST(:version_uid AS uuid)
+                """
+            ),
+            {"version_uid": version_uid},
+        )
+        .mappings()
+        .one_or_none()
+    )
+    if row is None:
+        return None
+    graph = dict(row["graph_document"] or {})
+    return {
+        "uid": str(row["uid"]),
+        "version_uid": str(row["version_uid"]),
+        "version": int(row["version"]),
+        "status": row["status"],
+        "name": row["name"],
+        "definition": f"Ontology {row['code']}",
+        "owner": row["owner"],
+        "business_domain_uid": row["business_domain_uid"],
+        "classes": graph.get("classes", []),
+        "properties": [
+            {
+                **item,
+                "uid": item.get("uid") or item.get("property_uid"),
+            }
+            for item in graph.get("properties", [])
+        ],
+    }
+
+
+def build_semantic_knowledge_handler(app, session):
+    """Build the canonical semantic handler when embeddings are configured."""
+    configured = app.extensions.get("semantic_knowledge_handler")
+    if configured is not None:
+        return configured
+    knowledge = _build_knowledge_sync(app, session)
+    if knowledge is None:
+        return None
+    from app.core.data_research.semantic_governance import (
+        SemanticGovernanceService,
+    )
+    from app.core.data_research.semantic_repository import (
+        SqlAlchemySemanticGovernanceRepository,
+    )
+
+    semantic = SemanticGovernanceService(
+        SqlAlchemySemanticGovernanceRepository(session)
+    )
+    return SemanticKnowledgeSyncHandler(semantic, knowledge)
+
+
+def build_governance_knowledge_handlers(app, session):
+    configured = app.extensions.get("governance_knowledge_handlers")
+    if configured is not None:
+        return dict(configured)
+    semantic = build_semantic_knowledge_handler(app, session)
+    if semantic is None:
+        return {}
+    knowledge = semantic.knowledge_sync
+    published = PublishedGovernanceKnowledgeHandler(
+        knowledge_sync=knowledge,
+        standard_loader=lambda uid: _load_standard(session, uid),
+        ontology_loader=lambda uid: _load_ontology(session, uid),
+    )
+    return {
+        "semantic_asset.version_published": semantic.handle,
+        "data_standard.version_published": published.handle_standard,
+        "ontology.version_published": published.handle_ontology,
+    }

+ 516 - 0
app/core/data_research/semantic_repository.py

@@ -0,0 +1,516 @@
+"""PostgreSQL repository for generic semantic governance."""
+
+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(row):
+    if row is None:
+        return None
+    result = dict(row)
+    for key, value in tuple(result.items()):
+        if isinstance(value, uuid.UUID):
+            result[key] = str(value)
+        elif hasattr(value, "isoformat"):
+            result[key] = value.isoformat()
+        elif isinstance(value, dict):
+            result[key] = dict(value)
+    return result
+
+
+class SqlAlchemySemanticGovernanceRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def get_by_code(self, asset_kind, code):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, asset_kind, code, name, owner_uid,
+                           business_domain_uid, status, current_version,
+                           created_by, created_at, updated_at
+                    FROM public.semantic_assets
+                    WHERE asset_kind = :asset_kind AND code = :code
+                    """
+                ),
+                {"asset_kind": asset_kind, "code": code},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def get(self, uid):
+        row = _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT asset.uid, asset.asset_kind, asset.code, asset.name,
+                           asset.owner_uid, asset.business_domain_uid,
+                           asset.status, asset.current_version,
+                           asset.created_by, asset.created_at, asset.updated_at,
+                           version.definition
+                    FROM public.semantic_assets asset
+                    JOIN public.semantic_asset_versions version
+                      ON version.asset_uid = asset.uid
+                     AND version.version = asset.current_version
+                    WHERE asset.uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return row
+
+    def list(self, *, asset_kind=None, status=None):
+        conditions = []
+        params = {}
+        if asset_kind:
+            conditions.append("asset.asset_kind = :asset_kind")
+            params["asset_kind"] = asset_kind
+        if status:
+            conditions.append("asset.status = :status")
+            params["status"] = status
+        where = "WHERE " + " AND ".join(conditions) if conditions else ""
+        statement = (
+            "SELECT asset.uid, asset.asset_kind, asset.code, asset.name, "
+            "asset.owner_uid, asset.business_domain_uid, asset.status, "
+            "asset.current_version, asset.created_by, asset.created_at, "
+            "asset.updated_at, version.definition "
+            "FROM public.semantic_assets asset "
+            "JOIN public.semantic_asset_versions version "
+            "ON version.asset_uid = asset.uid "
+            "AND version.version = asset.current_version "
+            f"{where} ORDER BY asset.asset_kind, asset.code"
+        )
+        return [
+            _row(item)
+            for item in self.session.execute(text(statement), params)
+            .mappings()
+            .all()
+        ]
+
+    def save_new(self, asset, version, audit, links):
+        self._insert_asset(asset)
+        self._insert_version(version)
+        self._insert_links(links)
+        self._insert_audit(audit)
+        return self.get(asset["uid"])
+
+    def _insert_asset(self, asset):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.semantic_assets (
+                    uid, asset_kind, code, name, owner_uid,
+                    business_domain_uid, status, current_version,
+                    created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :asset_kind, :code, :name,
+                    CAST(:owner_uid AS uuid),
+                    CAST(:business_domain_uid AS uuid), :status,
+                    :current_version, CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                """
+            ),
+            asset,
+        )
+
+    def _insert_version(self, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.semantic_asset_versions (
+                    uid, asset_uid, version, status, content_hash,
+                    definition, change_reason, created_by,
+                    rollback_from_version, created_at, published_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:asset_uid AS uuid),
+                    :version, :status, :content_hash,
+                    CAST(:definition AS jsonb), :change_reason,
+                    CAST(:created_by AS uuid), :rollback_from_version,
+                    CAST(:created_at AS timestamptz),
+                    CAST(:published_at AS timestamptz)
+                )
+                """
+            ),
+            {**version, "definition": _json(version["definition"])},
+        )
+
+    def _insert_links(self, links):
+        for link in links:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.semantic_asset_links (
+                        uid, asset_uid, version, link_type, target_type,
+                        target_uid, target_key, metadata
+                    ) VALUES (
+                        gen_random_uuid(), CAST(:asset_uid AS uuid), :version,
+                        :link_type, :target_type, :target_uid, :target_key,
+                        CAST(:metadata AS jsonb)
+                    )
+                    """
+                ),
+                {**link, "metadata": _json(link["metadata"])},
+            )
+
+    def _insert_audit(self, audit):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.semantic_publication_audits (
+                    uid, target_type, target_uid, target_version,
+                    action, actor_uid, detail, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :target_type,
+                    CAST(:target_uid AS uuid), :target_version,
+                    :action, CAST(:actor_uid AS uuid),
+                    CAST(:detail AS jsonb),
+                    CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {**audit, "detail": _json(audit["detail"])},
+        )
+
+    def list_versions(self, uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, asset_uid, version, status, content_hash,
+                           definition, change_reason, created_by,
+                           rollback_from_version, created_at, published_at
+                    FROM public.semantic_asset_versions
+                    WHERE asset_uid = CAST(:uid AS uuid)
+                    ORDER BY version
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_reviews(self, uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, asset_uid, version, decision, reason,
+                           reviewer_uid, created_at
+                    FROM public.semantic_asset_reviews
+                    WHERE asset_uid = CAST(:uid AS uuid)
+                    ORDER BY version, created_at
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_audits(self, uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, target_type, target_uid, target_version,
+                           action, actor_uid, detail, created_at
+                    FROM public.semantic_publication_audits
+                    WHERE target_uid = CAST(:uid AS uuid)
+                    ORDER BY created_at, uid
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def transition(self, asset, version, audit, review=None):
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.semantic_assets
+                SET status = :status,
+                    name = :name,
+                    owner_uid = CAST(:owner_uid AS uuid),
+                    business_domain_uid = CAST(:business_domain_uid AS uuid),
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :current_version
+                """
+            ),
+            asset,
+        )
+        if result.rowcount != 1:
+            raise RuntimeError("semantic asset version conflict")
+        self.session.execute(
+            text(
+                """
+                UPDATE public.semantic_asset_versions
+                SET status = :status,
+                    published_at = CAST(:published_at AS timestamptz)
+                WHERE asset_uid = CAST(:asset_uid AS uuid)
+                  AND version = :version
+                """
+            ),
+            version,
+        )
+        if review is not None:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.semantic_asset_reviews (
+                        uid, asset_uid, version, decision, reason,
+                        reviewer_uid, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:asset_uid AS uuid),
+                        :version, :decision, :reason,
+                        CAST(:reviewer_uid AS uuid),
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                review,
+            )
+        self._insert_audit(audit)
+        return self.get(asset["uid"])
+
+    def append_version(self, asset, version, audit, links):
+        if version["status"] == "published":
+            self.session.execute(
+                text(
+                    """
+                    UPDATE public.semantic_asset_versions
+                    SET status = 'superseded'
+                    WHERE asset_uid = CAST(:asset_uid AS uuid)
+                      AND status = 'published'
+                    """
+                ),
+                {"asset_uid": asset["uid"]},
+            )
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.semantic_assets
+                SET name = :name,
+                    owner_uid = CAST(:owner_uid AS uuid),
+                    business_domain_uid = CAST(:business_domain_uid AS uuid),
+                    status = :status,
+                    current_version = :current_version,
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :expected_version
+                """
+            ),
+            {**asset, "expected_version": int(asset["current_version"]) - 1},
+        )
+        if result.rowcount != 1:
+            raise RuntimeError("semantic asset version conflict")
+        self._insert_version(version)
+        self._insert_links(links)
+        self._insert_audit(audit)
+        return self.get(asset["uid"])
+
+    def active_assets_exist(self, uids):
+        if not uids:
+            return True
+        found = self.session.execute(
+            text(
+                """
+                SELECT count(*)
+                FROM public.active_metadata_assets
+                WHERE uid = ANY(CAST(:uids AS uuid[]))
+                  AND lifecycle_status = 'active'
+                """
+            ),
+            {"uids": uids},
+        ).scalar_one()
+        return int(found) == len(set(uids))
+
+    def published_data_elements_exist(self, uids):
+        if not uids:
+            return True
+        found = self.session.execute(
+            text(
+                """
+                SELECT count(*) FROM public.data_elements
+                WHERE uid = ANY(CAST(:uids AS uuid[]))
+                  AND status = 'published'
+                """
+            ),
+            {"uids": uids},
+        ).scalar_one()
+        return int(found) == len(set(uids))
+
+    def published_standard_versions_exist(self, uids):
+        if not uids:
+            return True
+        found = self.session.execute(
+            text(
+                """
+                SELECT count(*) FROM public.data_standard_versions
+                WHERE id = ANY(CAST(:uids AS uuid[]))
+                  AND status = 'published'
+                """
+            ),
+            {"uids": uids},
+        ).scalar_one()
+        return int(found) == len(set(uids))
+
+    def published_code_exists(self, code_set_uid, code):
+        return bool(
+            self.session.execute(
+                text(
+                    """
+                    SELECT 1
+                    FROM public.semantic_assets asset
+                    JOIN public.semantic_asset_versions version
+                      ON version.asset_uid = asset.uid
+                     AND version.version = asset.current_version
+                    CROSS JOIN LATERAL
+                      jsonb_array_elements(version.definition->'values') value
+                    WHERE asset.uid = CAST(:uid AS uuid)
+                      AND asset.asset_kind = 'code_set'
+                      AND asset.status = 'published'
+                      AND value->>'code' = :code
+                    LIMIT 1
+                    """
+                ),
+                {"uid": code_set_uid, "code": code},
+            ).scalar_one_or_none()
+        )
+
+    def get_active_metadata_asset(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, snapshot
+                    FROM public.active_metadata_assets
+                    WHERE uid = CAST(:uid AS uuid)
+                      AND lifecycle_status = 'active'
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def get_data_element(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, status, current_version
+                    FROM public.data_elements
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def save_field_mapping(self, mapping, audit):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_element_field_mappings (
+                    uid, asset_uid, field_name, data_element_uid,
+                    data_element_version, owner_uid, status, evidence,
+                    created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:asset_uid AS uuid), :field_name,
+                    CAST(:data_element_uid AS uuid), :data_element_version,
+                    CAST(:owner_uid AS uuid), :status,
+                    CAST(:evidence AS jsonb), CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                ON CONFLICT (asset_uid, field_name, data_element_uid)
+                DO NOTHING
+                """
+            ),
+            {**mapping, "evidence": _json(mapping["evidence"])},
+        )
+        found = self._field_mapping(
+            mapping["asset_uid"],
+            mapping["field_name"],
+            mapping["data_element_uid"],
+        )
+        if found["uid"] == mapping["uid"]:
+            self._insert_audit(audit)
+        return found
+
+    def _field_mapping(self, asset_uid, field_name, data_element_uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, asset_uid, field_name, data_element_uid,
+                           data_element_version, owner_uid, status, evidence,
+                           created_by, created_at, updated_at
+                    FROM public.data_element_field_mappings
+                    WHERE asset_uid = CAST(:asset_uid AS uuid)
+                      AND field_name = :field_name
+                      AND data_element_uid = CAST(:data_element_uid AS uuid)
+                    """
+                ),
+                {
+                    "asset_uid": asset_uid,
+                    "field_name": field_name,
+                    "data_element_uid": data_element_uid,
+                },
+            )
+            .mappings()
+            .one()
+        )
+
+    def list_field_mappings(self, *, asset_uid=None, data_element_uid=None):
+        conditions = []
+        params = {}
+        if asset_uid:
+            conditions.append("asset_uid = CAST(:asset_uid AS uuid)")
+            params["asset_uid"] = asset_uid
+        if data_element_uid:
+            conditions.append(
+                "data_element_uid = CAST(:data_element_uid AS uuid)"
+            )
+            params["data_element_uid"] = data_element_uid
+        where = "WHERE " + " AND ".join(conditions) if conditions else ""
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    "SELECT uid, asset_uid, field_name, data_element_uid, "
+                    "data_element_version, owner_uid, status, evidence, "
+                    "created_by, created_at, updated_at "
+                    f"FROM public.data_element_field_mappings {where} "
+                    "ORDER BY created_at, uid"
+                ),
+                params,
+            )
+            .mappings()
+            .all()
+        ]

+ 38 - 1
app/core/data_rules/repository.py

@@ -31,6 +31,7 @@ from app.core.data_rules.execution_contracts import (
     validate_dataset_binding,
     validate_schema_snapshot,
 )
+from app.core.events.outbox import enqueue_outbox
 
 RULE_CATEGORIES = {
     "general",
@@ -2849,7 +2850,43 @@ class DataRuleRepository:
             .one_or_none()
         )
         if row is not None:
-            return dict(row)
+            published = dict(row)
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.semantic_publication_audits (
+                        uid, target_type, target_uid, target_version,
+                        action, actor_uid, detail
+                    ) VALUES (
+                        CAST(:uid AS uuid), 'data_standard',
+                        CAST(:target_uid AS uuid), :target_version,
+                        'published', CAST(:actor_uid AS uuid),
+                        CAST(:detail AS jsonb)
+                    )
+                    """
+                ),
+                {
+                    "uid": new_governance_uid(),
+                    "target_uid": published["standard_uid"],
+                    "target_version": int(published["version_no"]),
+                    "actor_uid": published_by,
+                    "detail": _json(
+                        {"version_uid": published["id"]}
+                    ),
+                },
+            )
+            enqueue_outbox(
+                self.session,
+                aggregate_type="data_standard",
+                aggregate_id=published["standard_uid"],
+                event_type="data_standard.version_published",
+                payload={
+                    "standard_uid": published["standard_uid"],
+                    "version_uid": published["id"],
+                    "version": int(published["version_no"]),
+                },
+            )
+            return published
         status = self.session.execute(
             text(
                 "SELECT status /* current_version_status */ "

+ 17 - 4
app/core/events/outbox.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from datetime import datetime, timezone
+from datetime import UTC, datetime
 from typing import Any
 
 from sqlalchemy import text
@@ -44,7 +44,13 @@ def enqueue_outbox(
     return event_id
 
 
-def claim_outbox(session: Any, limit: int = 50) -> list[ClaimedEvent]:
+def claim_outbox(
+    session: Any,
+    limit: int = 50,
+    event_types: list[str] | None = None,
+) -> list[ClaimedEvent]:
+    if event_types is not None and not event_types:
+        return []
     rows = session.execute(
         text(
             """
@@ -52,6 +58,10 @@ def claim_outbox(session: Any, limit: int = 50) -> list[ClaimedEvent]:
                 SELECT event_id
                 FROM public.outbox_events
                 WHERE status = 'pending' AND available_at <= CURRENT_TIMESTAMP
+                  AND (
+                    CAST(:event_types AS text[]) IS NULL
+                    OR event_type = ANY(CAST(:event_types AS text[]))
+                  )
                 ORDER BY available_at, created_at
                 FOR UPDATE SKIP LOCKED
                 LIMIT :limit
@@ -64,7 +74,10 @@ def claim_outbox(session: Any, limit: int = 50) -> list[ClaimedEvent]:
                       event.payload, event.attempts
             """
         ),
-        {"limit": int(limit)},
+        {
+            "limit": int(limit),
+            "event_types": event_types,
+        },
     )
     return [
         ClaimedEvent(
@@ -151,7 +164,7 @@ def outbox_health(session: Any) -> dict[str, Any]:
         "backlog": int(row[0] or 0),
         "failed": int(row[1] or 0),
         "oldest_age_seconds": float(row[2] or 0),
-        "checked_at": datetime.now(timezone.utc).isoformat(),
+        "checked_at": datetime.now(UTC).isoformat(),
     }
 
 

+ 24 - 2
app/core/knowledge/point_builder.py

@@ -24,11 +24,15 @@ SECRET_MARKERS = (
 VOLATILE_KEYS = {"created_at", "updated_at"}
 SUPPORTED_TYPES = {
     "BusinessDomain",
+    "BusinessTerm",
+    "CodeSet",
     "DataFlow",
     "DataMeta",
     "DataStandard",
     "Label",
     "DataLabel",
+    "MetricDefinition",
+    "Ontology",
 }
 
 
@@ -146,6 +150,8 @@ def build_knowledge_snapshot(
         ("purpose", ("purpose", "script_requirement")),
         ("owner", ("owner", "owner_name")),
         ("data_type", ("data_type", "type")),
+        ("formula", ("formula",)),
+        ("unit", ("unit",)),
     )
     for semantic_path, keys in scalar_specs:
         value = _first(source, *keys)
@@ -197,9 +203,25 @@ def build_knowledge_snapshot(
             )
         )
 
-    for collection_name in ("fields", "rules"):
+    for collection_name in (
+        "fields",
+        "rules",
+        "values",
+        "dimensions",
+        "classes",
+        "properties",
+    ):
         for item in _iter_dicts(source.get(collection_name)):
-            item_uid = _require_child_uid(item, collection_name[:-1])
+            item_uid = (
+                _require_child_uid(item, collection_name[:-1])
+                if collection_name
+                in {"fields", "rules", "classes", "properties"}
+                else str(
+                    item.get("uid")
+                    or item.get("code")
+                    or _require_child_uid(item, collection_name[:-1])
+                )
+            )
             for semantic_name, keys in (
                 ("name", ("name_zh", "name", "name_en")),
                 ("definition", ("definition", "description")),

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

@@ -52,6 +52,9 @@ DOMAIN_TEMPLATES_MANAGE = "domain-templates:manage"
 ACTIVE_METADATA_READ = "active-metadata:read"
 ACTIVE_METADATA_OPERATE = "active-metadata:operate"
 ACTIVE_METADATA_MANAGE = "active-metadata:manage"
+SEMANTIC_GOVERNANCE_EDIT = "semantic-governance:edit"
+SEMANTIC_GOVERNANCE_REVIEW = "semantic-governance:review"
+SEMANTIC_GOVERNANCE_PUBLISH = "semantic-governance:publish"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -87,6 +90,7 @@ ROLE_PERMISSIONS = {
             DOMAIN_TEMPLATES_PREVIEW,
             ACTIVE_METADATA_READ,
             ACTIVE_METADATA_OPERATE,
+            SEMANTIC_GOVERNANCE_EDIT,
         }
     ),
     "admin": frozenset(
@@ -136,6 +140,9 @@ ROLE_PERMISSIONS = {
             ACTIVE_METADATA_READ,
             ACTIVE_METADATA_OPERATE,
             ACTIVE_METADATA_MANAGE,
+            SEMANTIC_GOVERNANCE_EDIT,
+            SEMANTIC_GOVERNANCE_REVIEW,
+            SEMANTIC_GOVERNANCE_PUBLISH,
         }
     ),
 }
@@ -214,6 +221,14 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         return (MANAGE_USERS,)
     if path.startswith("/api/development/v1/governance-metrics"):
         return (READ_GOVERNANCE,)
+    if path.startswith("/api/development/v1/semantic"):
+        if method == "GET":
+            return (READ_GOVERNANCE,)
+        if path.endswith("/review"):
+            return (SEMANTIC_GOVERNANCE_REVIEW,)
+        if path.endswith("/publish") or path.endswith("/rollback"):
+            return (SEMANTIC_GOVERNANCE_PUBLISH,)
+        return (SEMANTIC_GOVERNANCE_EDIT,)
     if path.startswith("/api/development/v1/device-assets"):
         if method == "GET":
             return (READ_GOVERNANCE,)

+ 14 - 0
app/models/__init__.py

@@ -34,6 +34,14 @@ from app.models.governance_template import (
     GovernanceObjectType,
 )
 from app.models.metadata_review import MetadataReviewRecord, MetadataVersionHistory
+from app.models.semantic_governance import (
+    DataElementFieldMapping,
+    SemanticAsset,
+    SemanticAssetLink,
+    SemanticAssetReview,
+    SemanticAssetVersion,
+    SemanticPublicationAudit,
+)
 
 __all__ = [
     "ActiveMetadataPlan",
@@ -49,6 +57,12 @@ __all__ = [
     "DataProduct",
     "MetadataReviewRecord",
     "MetadataVersionHistory",
+    "SemanticAsset",
+    "SemanticAssetVersion",
+    "SemanticAssetReview",
+    "SemanticAssetLink",
+    "DataElementFieldMapping",
+    "SemanticPublicationAudit",
     "GovernanceDomainTemplate",
     "GovernanceDomainTemplateVersion",
     "GovernanceObjectType",

+ 102 - 0
app/models/semantic_governance.py

@@ -0,0 +1,102 @@
+"""SQLAlchemy mappings for generic semantic governance."""
+
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+
+from app import db
+from app.core.common.identifiers import new_governance_uid
+
+
+class SemanticAsset(db.Model):
+    __tablename__ = "semantic_assets"
+    __table_args__ = (
+        db.UniqueConstraint("asset_kind", "code"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_kind = db.Column(db.String(30), nullable=False)
+    code = db.Column(db.String(120), nullable=False)
+    name = db.Column(db.String(300), nullable=False)
+    owner_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    business_domain_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    current_version = db.Column(db.Integer, nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class SemanticAssetVersion(db.Model):
+    __tablename__ = "semantic_asset_versions"
+    __table_args__ = (
+        db.UniqueConstraint("asset_uid", "version"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    version = db.Column(db.Integer, nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    content_hash = db.Column(db.String(64), nullable=False)
+    definition = db.Column(JSONB, nullable=False)
+    change_reason = db.Column(db.String(1000), nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+    rollback_from_version = db.Column(db.Integer)
+
+
+class SemanticAssetReview(db.Model):
+    __tablename__ = "semantic_asset_reviews"
+    __table_args__ = (
+        db.UniqueConstraint("asset_uid", "version"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    version = db.Column(db.Integer, nullable=False)
+    decision = db.Column(db.String(20), nullable=False)
+    reason = db.Column(db.String(1000), nullable=False)
+    reviewer_uid = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class SemanticAssetLink(db.Model):
+    __tablename__ = "semantic_asset_links"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    version = db.Column(db.Integer, nullable=False)
+    link_type = db.Column(db.String(30), nullable=False)
+    target_type = db.Column(db.String(60), nullable=False)
+    target_uid = db.Column(db.String(200), nullable=False)
+    target_key = db.Column(db.String(200))
+    metadata_json = db.Column("metadata", JSONB, nullable=False)
+
+
+class DataElementFieldMapping(db.Model):
+    __tablename__ = "data_element_field_mappings"
+    __table_args__ = (
+        db.UniqueConstraint("asset_uid", "field_name", "data_element_uid"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    field_name = db.Column(db.String(200), nullable=False)
+    data_element_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    data_element_version = db.Column(db.Integer, nullable=False)
+    owner_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    evidence = db.Column(JSONB, nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class SemanticPublicationAudit(db.Model):
+    __tablename__ = "semantic_publication_audits"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    target_type = db.Column(db.String(40), nullable=False)
+    target_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    target_version = db.Column(db.Integer, nullable=False)
+    action = db.Column(db.String(40), nullable=False)
+    actor_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    detail = db.Column(JSONB, nullable=False)

+ 6 - 1
app/services/embedding/qwen.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
+
 import hashlib
-from typing import Any
+
 import requests
 
 
@@ -15,6 +16,10 @@ class QwenEmbeddingClient:
         self.api_key, self.base_url, self.model = api_key, base_url.rstrip("/"), model
         self.dimension, self.timeout = dimension, timeout
 
+    @property
+    def profile_key(self) -> str:
+        return f"qwen:{self.model}:{self.dimension}"
+
     def cache_key(self, text: str) -> str:
         return hashlib.sha256(f"{self.model}:{self.dimension}:{text}".encode()).hexdigest()
 

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

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

+ 245 - 0
deployment/app/api/data_development/semantic_routes.py

@@ -0,0 +1,245 @@
+"""HTTP boundary for governed terms, code sets, metrics and field mappings."""
+
+from __future__ import annotations
+
+import io
+
+from flask import g, jsonify, request, send_file
+
+from app import db
+from app.api.data_development import bp
+from app.models.result import failed, success
+
+
+def get_semantic_governance_service():
+    from app.core.data_research.semantic_governance import (
+        SemanticGovernanceService,
+    )
+    from app.core.data_research.semantic_repository import (
+        SqlAlchemySemanticGovernanceRepository,
+    )
+    from app.core.events.outbox import enqueue_outbox
+
+    return SemanticGovernanceService(
+        SqlAlchemySemanticGovernanceRepository(db.session),
+        outbox_enqueue=lambda **event: enqueue_outbox(db.session, **event),
+    )
+
+
+def _actor_uid():
+    identity = getattr(g, "current_user", {}) or {}
+    return identity.get("id") or identity.get("sub")
+
+
+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.route("/semantic-assets", methods=["GET"])
+def list_semantic_assets():
+    try:
+        records = get_semantic_governance_service().list(
+            asset_kind=request.args.get("asset_kind"),
+            status=request.args.get("status"),
+        )
+        return jsonify(success(records)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets", methods=["POST"])
+def create_semantic_asset():
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().create_draft(
+            payload.get("asset_kind"),
+            {key: value for key, value in payload.items() if key != "asset_kind"},
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>", methods=["GET"])
+def get_semantic_asset(asset_uid):
+    try:
+        return jsonify(
+            success(get_semantic_governance_service().get(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/versions", methods=["GET"])
+def list_semantic_asset_versions(asset_uid):
+    try:
+        return jsonify(
+            success(
+                get_semantic_governance_service().list_versions(asset_uid)
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/reviews", methods=["GET"])
+def list_semantic_asset_reviews(asset_uid):
+    try:
+        return jsonify(
+            success(get_semantic_governance_service().list_reviews(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/audits", methods=["GET"])
+def list_semantic_asset_audits(asset_uid):
+    try:
+        return jsonify(
+            success(get_semantic_governance_service().list_audits(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/revisions", methods=["POST"])
+def revise_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().revise(
+            asset_uid,
+            payload.get("definition") or {},
+            expected_version=int(payload.get("expected_version")),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/submit", methods=["POST"])
+def submit_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().submit(
+            asset_uid,
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/review", methods=["POST"])
+def review_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().review(
+            asset_uid,
+            expected_version=int(payload.get("expected_version")),
+            decision=payload.get("decision"),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/publish", methods=["POST"])
+def publish_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().publish(
+            asset_uid,
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/rollback", methods=["POST"])
+def rollback_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().rollback(
+            asset_uid,
+            target_version=int(payload.get("target_version")),
+            expected_version=int(payload.get("expected_version")),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-field-mappings", methods=["GET"])
+def list_semantic_field_mappings():
+    try:
+        records = get_semantic_governance_service().list_field_mappings(
+            asset_uid=request.args.get("asset_uid"),
+            data_element_uid=request.args.get("data_element_uid"),
+        )
+        return jsonify(success(records)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-field-mappings", methods=["POST"])
+def create_semantic_field_mapping():
+    try:
+        record = get_semantic_governance_service().map_physical_field(
+            request.get_json(silent=True) or {},
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/export", methods=["GET"])
+def export_semantic_assets():
+    from app.core.data_research.semantic_governance import SemanticExchange
+
+    export_format = str(request.args.get("format") or "json").lower()
+    try:
+        assets = get_semantic_governance_service().list(status="published")
+        exchange = SemanticExchange()
+        if export_format == "rdf":
+            content = exchange.export_rdf(assets)
+            filename = "semantic-governance.rdf"
+            mimetype = "application/rdf+xml"
+        elif export_format == "json":
+            content = exchange.export_json(assets)
+            filename = "semantic-governance.json"
+            mimetype = "application/json"
+        else:
+            raise ValueError("semantic export format is unsupported")
+        return send_file(
+            io.BytesIO(content),
+            mimetype=mimetype,
+            as_attachment=True,
+            download_name=filename,
+        )
+    except Exception as error:
+        return _error(error)

+ 34 - 7
deployment/app/commands/process_outbox.py

@@ -2,7 +2,8 @@ from __future__ import annotations
 
 import argparse
 import logging
-from typing import Any, Callable
+from collections.abc import Callable
+from typing import Any
 
 from app import create_app, db
 from app.core.events.consumer import dispatch_event
@@ -13,10 +14,34 @@ from app.core.events.outbox import (
     reset_stale_processing,
 )
 
-
 logger = logging.getLogger(__name__)
 
 
+def _missing_handler(event_type):
+    def handler(_payload):
+        raise ValueError(f"no handler registered for {event_type}")
+
+    return handler
+
+
+def build_governance_knowledge_handlers(app, session):
+    from app.core.data_research.semantic_knowledge import (
+        build_governance_knowledge_handlers as build_handlers,
+    )
+
+    return build_handlers(app, session)
+
+
+def build_default_handlers(app, session):
+    handlers = build_governance_knowledge_handlers(app, session)
+    if not handlers:
+        logger.warning(
+            "Governance knowledge sync is not configured; "
+            "publication events will remain pending"
+        )
+    return handlers
+
+
 def process_available_events(
     session: Any,
     handlers: dict[str, Callable[[dict[str, Any]], None]],
@@ -29,13 +54,15 @@ def process_available_events(
         logger.info("No outbox handlers registered; leaving events pending")
         return 0
 
-    events = claim_outbox(session, limit=batch_size)
+    events = claim_outbox(
+        session,
+        limit=batch_size,
+        event_types=sorted(handlers),
+    )
     for event in events:
         handler = handlers.get(event.event_type)
         if handler is None:
-            handler = lambda _payload: (_ for _ in ()).throw(
-                ValueError(f"no handler registered for {event.event_type}")
-            )
+            handler = _missing_handler(event.event_type)
         result = dispatch_event(
             event,
             handler,
@@ -65,7 +92,7 @@ def main() -> None:
         )
         processed = process_available_events(
             db.session,
-            handlers={},
+            handlers=build_default_handlers(app, db.session),
             batch_size=args.batch_size,
         )
         logger.info("Processed %s outbox events", processed)

+ 770 - 0
deployment/app/core/data_research/semantic_governance.py

@@ -0,0 +1,770 @@
+"""Generic business terms, code sets, metrics and physical-field mappings."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import uuid
+import xml.etree.ElementTree as ET
+from copy import deepcopy
+from datetime import UTC, datetime
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+
+ASSET_KINDS = frozenset({"business_term", "code_set", "metric"})
+ASSET_STATUSES = frozenset(
+    {"draft", "in_review", "approved", "published", "superseded", "retired"}
+)
+SECRET_MARKERS = (
+    "password",
+    "secret",
+    "token",
+    "credential",
+    "api_key",
+    "private_key",
+    "authorization",
+    "connection_string",
+)
+ANALYSIS_EXECUTION_FIELDS = frozenset(
+    {"execution_sql", "sql", "query", "script", "executable", "runtime"}
+)
+RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+DATAOPS_NS = "https://dataops.local/semantic#"
+
+
+def _canonical(value: Any) -> str:
+    return json.dumps(
+        value,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    )
+
+
+def _hash(value: Any) -> str:
+    return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (TypeError, ValueError, AttributeError) as exc:
+        raise ValueError(f"{field} must be a UUID") from exc
+
+
+def _text(value: Any, field: str, maximum: int = 500) -> str:
+    result = str(value or "").strip()
+    if not result or len(result) > maximum:
+        raise ValueError(f"{field} must be between 1 and {maximum} characters")
+    return result
+
+
+def _optional_text(value: Any, field: str, maximum: int = 500) -> str | None:
+    if value is None:
+        return None
+    return _text(value, field, maximum)
+
+
+def _reject_secrets(value: Any, path: str = "payload") -> None:
+    if isinstance(value, dict):
+        for key, nested in value.items():
+            normalized = str(key).casefold().replace("-", "_")
+            if any(marker in normalized for marker in SECRET_MARKERS):
+                raise ValueError(f"secret-bearing field is not allowed at {path}.{key}")
+            _reject_secrets(nested, f"{path}.{key}")
+    elif isinstance(value, list):
+        for index, nested in enumerate(value):
+            _reject_secrets(nested, f"{path}[{index}]")
+
+
+def _uuid_list(value: Any, field: str, maximum: int = 500) -> list[str]:
+    if value is None:
+        return []
+    if not isinstance(value, list) or len(value) > maximum:
+        raise ValueError(f"{field} must be a bounded list")
+    result = [_uuid(item, field) for item in value]
+    if len(set(result)) != len(result):
+        raise ValueError(f"{field} contains duplicates")
+    return result
+
+
+def _aliases(value: Any) -> list[str]:
+    if value is None:
+        return []
+    if not isinstance(value, list) or len(value) > 100:
+        raise ValueError("aliases must be a bounded list")
+    result = [_text(item, "alias", 200) for item in value]
+    if len(set(result)) != len(result):
+        raise ValueError("aliases contains duplicates")
+    return sorted(result)
+
+
+def _code_references(value: Any) -> list[dict[str, str]]:
+    if value is None:
+        return []
+    if not isinstance(value, list) or len(value) > 500:
+        raise ValueError("code_references must be a bounded list")
+    result = []
+    seen = set()
+    for item in value:
+        if not isinstance(item, dict):
+            raise ValueError("code reference must be an object")
+        reference = {
+            "code_set_uid": _uuid(item.get("code_set_uid"), "code_set_uid"),
+            "code": _text(item.get("code"), "code reference", 120),
+        }
+        key = (reference["code_set_uid"], reference["code"])
+        if key in seen:
+            raise ValueError("code_references contains duplicates")
+        seen.add(key)
+        result.append(reference)
+    return sorted(result, key=lambda item: (item["code_set_uid"], item["code"]))
+
+
+def _code_values(value: Any) -> list[dict[str, Any]]:
+    if not isinstance(value, list) or not value or len(value) > 10_000:
+        raise ValueError("values must contain between 1 and 10000 items")
+    result = []
+    codes = set()
+    for item in value:
+        if not isinstance(item, dict):
+            raise ValueError("code value must be an object")
+        code = _text(item.get("code"), "code value", 120)
+        if code in codes:
+            raise ValueError(f"duplicate code value {code}")
+        codes.add(code)
+        result.append(
+            {
+                "code": code,
+                "name": _text(item.get("name"), "code value name", 300),
+                "definition": _optional_text(
+                    item.get("definition"), "code value definition", 1000
+                ),
+                "parent_code": _optional_text(
+                    item.get("parent_code"), "parent_code", 120
+                ),
+                "status": str(item.get("status") or "active").strip().lower(),
+            }
+        )
+    for item in result:
+        if item["status"] not in {"active", "deprecated"}:
+            raise ValueError("code value status is unsupported")
+        if item["parent_code"] and item["parent_code"] not in codes:
+            raise ValueError(
+                f"parent_code {item['parent_code']} does not reference this code set"
+            )
+        if item["parent_code"] == item["code"]:
+            raise ValueError("parent_code cannot reference itself")
+    return result
+
+
+def _dimensions(value: Any) -> list[dict[str, str | None]]:
+    if not isinstance(value, list) or not value or len(value) > 100:
+        raise ValueError("dimensions must contain between 1 and 100 items")
+    result = []
+    seen = set()
+    for item in value:
+        if not isinstance(item, dict):
+            raise ValueError("dimension must be an object")
+        code = _text(item.get("code"), "dimension code", 120)
+        if code in seen:
+            raise ValueError(f"duplicate dimension {code}")
+        seen.add(code)
+        result.append(
+            {
+                "code": code,
+                "name": _text(item.get("name"), "dimension name", 300),
+                "definition": _optional_text(
+                    item.get("definition"), "dimension definition", 1000
+                ),
+            }
+        )
+    return result
+
+
+def _normalize_definition(asset_kind: str, payload: dict[str, Any]) -> dict[str, Any]:
+    common = {
+        "definition": _text(payload.get("definition"), "definition", 4000),
+        "related_asset_uids": _uuid_list(
+            payload.get("related_asset_uids"), "related_asset_uids"
+        ),
+        "related_data_element_uids": _uuid_list(
+            payload.get("related_data_element_uids"),
+            "related_data_element_uids",
+        ),
+        "standard_version_uids": _uuid_list(
+            payload.get("standard_version_uids"),
+            "standard_version_uids",
+        ),
+        "code_references": _code_references(payload.get("code_references")),
+    }
+    if asset_kind == "business_term":
+        common["aliases"] = _aliases(payload.get("aliases"))
+    elif asset_kind == "code_set":
+        common["values"] = _code_values(payload.get("values"))
+    elif asset_kind == "metric":
+        forbidden = sorted(set(payload) & ANALYSIS_EXECUTION_FIELDS)
+        if forbidden:
+            raise ValueError(
+                "metric is a definition only; analysis execution fields are forbidden"
+            )
+        common.update(
+            {
+                "formula": _text(payload.get("formula"), "formula", 4000),
+                "unit": _text(payload.get("unit"), "unit", 80),
+                "dimensions": _dimensions(payload.get("dimensions")),
+            }
+        )
+    return common
+
+
+def _links(asset_uid: str, version: int, definition: dict[str, Any]) -> list[dict]:
+    result = []
+    for link_type, key, target_type in (
+        ("asset", "related_asset_uids", "active_metadata_asset"),
+        ("data_element", "related_data_element_uids", "data_element"),
+        ("standard", "standard_version_uids", "data_standard_version"),
+    ):
+        result.extend(
+            {
+                "asset_uid": asset_uid,
+                "version": version,
+                "link_type": link_type,
+                "target_type": target_type,
+                "target_uid": uid,
+                "target_key": None,
+                "metadata": {},
+            }
+            for uid in definition.get(key, [])
+        )
+    result.extend(
+        {
+            "asset_uid": asset_uid,
+            "version": version,
+            "link_type": "code_value",
+            "target_type": "code_set",
+            "target_uid": item["code_set_uid"],
+            "target_key": item["code"],
+            "metadata": {},
+        }
+        for item in definition.get("code_references", [])
+    )
+    return result
+
+
+class SemanticGovernanceService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory=new_governance_uid,
+        now_factory=lambda: datetime.now(UTC),
+        outbox_enqueue=lambda **_event: None,
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.outbox_enqueue = outbox_enqueue
+
+    def _now(self) -> str:
+        return self.now_factory().isoformat()
+
+    def _audit(
+        self,
+        uid: str,
+        version: int,
+        action: str,
+        actor_uid: str,
+        detail: dict[str, Any] | None = None,
+    ) -> dict[str, Any]:
+        return {
+            "uid": self.uid_factory(),
+            "target_type": "semantic_asset",
+            "target_uid": uid,
+            "target_version": version,
+            "action": action,
+            "actor_uid": _uuid(actor_uid, "actor_uid"),
+            "detail": deepcopy(detail or {}),
+            "created_at": self._now(),
+        }
+
+    def _validate_references(self, definition: dict[str, Any]) -> None:
+        asset_uids = definition.get("related_asset_uids", [])
+        if asset_uids and not self.repository.active_assets_exist(asset_uids):
+            raise ValueError("related asset must exist in active metadata")
+        element_uids = definition.get("related_data_element_uids", [])
+        if element_uids and not self.repository.published_data_elements_exist(
+            element_uids
+        ):
+            raise ValueError("related data element must be published")
+        standard_uids = definition.get("standard_version_uids", [])
+        if standard_uids and not self.repository.published_standard_versions_exist(
+            standard_uids
+        ):
+            raise ValueError("related standard version must be published")
+        for reference in definition.get("code_references", []):
+            if not self.repository.published_code_exists(
+                reference["code_set_uid"], reference["code"]
+            ):
+                raise ValueError("published code reference was not found")
+
+    def create_draft(self, asset_kind: str, payload: Any, *, actor_uid: str):
+        if asset_kind not in ASSET_KINDS:
+            raise ValueError("semantic asset kind is unsupported")
+        if not isinstance(payload, dict):
+            raise ValueError("semantic asset payload must be an object")
+        _reject_secrets(payload)
+        code = _text(payload.get("code"), "code", 120)
+        if self.repository.get_by_code(asset_kind, code) is not None:
+            raise ValueError("semantic asset code already exists")
+        definition = _normalize_definition(asset_kind, payload)
+        self._validate_references(definition)
+        uid = self.uid_factory()
+        now = self._now()
+        asset = {
+            "uid": uid,
+            "asset_kind": asset_kind,
+            "code": code,
+            "name": _text(payload.get("name"), "name", 300),
+            "owner_uid": _uuid(payload.get("owner_uid"), "owner_uid"),
+            "business_domain_uid": _uuid(
+                payload.get("business_domain_uid"), "business_domain_uid"
+            ),
+            "status": "draft",
+            "current_version": 1,
+            "created_by": _uuid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "updated_at": now,
+        }
+        version = {
+            "uid": self.uid_factory(),
+            "asset_uid": uid,
+            "version": 1,
+            "status": "draft",
+            "content_hash": _hash(definition),
+            "definition": definition,
+            "change_reason": "initial draft",
+            "created_by": asset["created_by"],
+            "rollback_from_version": None,
+            "created_at": now,
+            "published_at": None,
+        }
+        return self.repository.save_new(
+            asset,
+            version,
+            self._audit(uid, 1, "created", actor_uid),
+            _links(uid, 1, definition),
+        )
+
+    def get(self, uid: str):
+        result = self.repository.get(_uuid(uid, "asset_uid"))
+        if result is None:
+            raise LookupError("semantic asset was not found")
+        return result
+
+    def list(self, *, asset_kind: str | None = None, status: str | None = None):
+        if asset_kind is not None and asset_kind not in ASSET_KINDS:
+            raise ValueError("semantic asset kind is unsupported")
+        if status is not None and status not in ASSET_STATUSES:
+            raise ValueError("semantic asset status is unsupported")
+        return self.repository.list(asset_kind=asset_kind, status=status)
+
+    def list_versions(self, uid: str):
+        self.get(uid)
+        return self.repository.list_versions(uid)
+
+    def get_published_version(self, uid: str, version: int):
+        asset = self.get(uid)
+        record = next(
+            (
+                item
+                for item in self.repository.list_versions(asset["uid"])
+                if int(item["version"]) == int(version)
+            ),
+            None,
+        )
+        if record is None or record["status"] not in {"published", "superseded"}:
+            raise LookupError("published semantic asset version was not found")
+        return {
+            **asset,
+            "status": record["status"],
+            "current_version": int(record["version"]),
+            "definition": deepcopy(record["definition"]),
+        }
+
+    def list_reviews(self, uid: str):
+        self.get(uid)
+        return self.repository.list_reviews(uid)
+
+    def list_audits(self, uid: str):
+        self.get(uid)
+        return self.repository.list_audits(uid)
+
+    def _version(self, asset: dict[str, Any]) -> dict[str, Any]:
+        versions = self.repository.list_versions(asset["uid"])
+        return next(
+            item
+            for item in versions
+            if int(item["version"]) == int(asset["current_version"])
+        )
+
+    def _expected(
+        self, uid: str, expected_version: int
+    ) -> tuple[dict[str, Any], dict[str, Any]]:
+        asset = self.get(uid)
+        if int(expected_version) != int(asset["current_version"]):
+            raise ValueError(
+                f"expected version {expected_version}, "
+                f"current version {asset['current_version']}"
+            )
+        return asset, self._version(asset)
+
+    def submit(
+        self,
+        uid: str,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        asset, version = self._expected(uid, expected_version)
+        if asset["status"] != "draft":
+            raise ValueError("only a draft semantic asset can be submitted")
+        asset.update({"status": "in_review", "updated_at": self._now()})
+        version["status"] = "in_review"
+        return self.repository.transition(
+            asset,
+            version,
+            self._audit(uid, asset["current_version"], "submitted", actor_uid),
+        )
+
+    def review(
+        self,
+        uid: str,
+        *,
+        expected_version: int,
+        decision: str,
+        reason: str,
+        actor_uid: str,
+    ):
+        asset, version = self._expected(uid, expected_version)
+        reviewer_uid = _uuid(actor_uid, "actor_uid")
+        if reviewer_uid == str(version["created_by"]):
+            raise ValueError("semantic asset self-review is not allowed")
+        if asset["status"] != "in_review":
+            raise ValueError("only an in-review semantic asset can be reviewed")
+        decision = str(decision or "").strip().lower()
+        if decision not in {"approve", "reject"}:
+            raise ValueError("semantic review decision is unsupported")
+        reason = _text(reason, "review reason", 1000)
+        target_status = "approved" if decision == "approve" else "draft"
+        asset.update({"status": target_status, "updated_at": self._now()})
+        version["status"] = target_status
+        review = {
+            "uid": self.uid_factory(),
+            "asset_uid": asset["uid"],
+            "version": asset["current_version"],
+            "decision": decision,
+            "reason": reason,
+            "reviewer_uid": reviewer_uid,
+            "created_at": self._now(),
+        }
+        return self.repository.transition(
+            asset,
+            version,
+            self._audit(
+                uid,
+                asset["current_version"],
+                "approved" if decision == "approve" else "rejected",
+                actor_uid,
+                {"reason": reason},
+            ),
+            review,
+        )
+
+    def publish(
+        self,
+        uid: str,
+        *,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        asset, version = self._expected(uid, expected_version)
+        if asset["status"] != "approved":
+            raise ValueError("only an approved semantic asset can be published")
+        self._validate_references(version["definition"])
+        now = self._now()
+        asset.update({"status": "published", "updated_at": now})
+        version.update({"status": "published", "published_at": now})
+        published = self.repository.transition(
+            asset,
+            version,
+            self._audit(uid, asset["current_version"], "published", actor_uid),
+        )
+        self._enqueue_publication(published)
+        return published
+
+    def revise(
+        self,
+        uid: str,
+        payload: Any,
+        *,
+        expected_version: int,
+        reason: str,
+        actor_uid: str,
+    ):
+        asset, _current = self._expected(uid, expected_version)
+        if asset["status"] not in {"published", "draft"}:
+            raise ValueError("semantic asset cannot be revised in its current status")
+        if not isinstance(payload, dict):
+            raise ValueError("semantic revision payload must be an object")
+        _reject_secrets(payload)
+        merged = {
+            "code": asset["code"],
+            "name": payload.get("name") or asset["name"],
+            "owner_uid": payload.get("owner_uid") or asset["owner_uid"],
+            "business_domain_uid": (
+                payload.get("business_domain_uid") or asset["business_domain_uid"]
+            ),
+            **payload,
+        }
+        definition = _normalize_definition(asset["asset_kind"], merged)
+        self._validate_references(definition)
+        now = self._now()
+        version_number = int(asset["current_version"]) + 1
+        asset.update(
+            {
+                "name": _text(merged.get("name"), "name", 300),
+                "owner_uid": _uuid(merged.get("owner_uid"), "owner_uid"),
+                "business_domain_uid": _uuid(
+                    merged.get("business_domain_uid"), "business_domain_uid"
+                ),
+                "status": "draft",
+                "current_version": version_number,
+                "updated_at": now,
+            }
+        )
+        version = {
+            "uid": self.uid_factory(),
+            "asset_uid": asset["uid"],
+            "version": version_number,
+            "status": "draft",
+            "content_hash": _hash(definition),
+            "definition": definition,
+            "change_reason": _text(reason, "change reason", 1000),
+            "created_by": _uuid(actor_uid, "actor_uid"),
+            "rollback_from_version": None,
+            "created_at": now,
+            "published_at": None,
+        }
+        return self.repository.append_version(
+            asset,
+            version,
+            self._audit(
+                uid,
+                version_number,
+                "revised",
+                actor_uid,
+                {"reason": version["change_reason"]},
+            ),
+            _links(uid, version_number, definition),
+        )
+
+    def rollback(
+        self,
+        uid: str,
+        *,
+        target_version: int,
+        expected_version: int,
+        reason: str,
+        actor_uid: str,
+    ):
+        asset, _current = self._expected(uid, expected_version)
+        target = next(
+            (
+                item
+                for item in self.repository.list_versions(uid)
+                if int(item["version"]) == int(target_version)
+            ),
+            None,
+        )
+        if target is None or target["status"] not in {"published", "superseded"}:
+            raise ValueError("rollback target must be a published version")
+        now = self._now()
+        version_number = int(asset["current_version"]) + 1
+        definition = deepcopy(target["definition"])
+        asset.update(
+            {
+                "status": "published",
+                "current_version": version_number,
+                "updated_at": now,
+            }
+        )
+        version = {
+            "uid": self.uid_factory(),
+            "asset_uid": asset["uid"],
+            "version": version_number,
+            "status": "published",
+            "content_hash": _hash(definition),
+            "definition": definition,
+            "change_reason": _text(reason, "rollback reason", 1000),
+            "created_by": _uuid(actor_uid, "actor_uid"),
+            "rollback_from_version": int(target_version),
+            "created_at": now,
+            "published_at": now,
+        }
+        rolled_back = self.repository.append_version(
+            asset,
+            version,
+            self._audit(
+                uid,
+                version_number,
+                "rolled_back",
+                actor_uid,
+                {
+                    "target_version": int(target_version),
+                    "reason": version["change_reason"],
+                },
+            ),
+            _links(uid, version_number, definition),
+        )
+        self._enqueue_publication(rolled_back)
+        return rolled_back
+
+    def _enqueue_publication(self, asset: dict[str, Any]) -> None:
+        self.outbox_enqueue(
+            aggregate_type="semantic_asset",
+            aggregate_id=asset["uid"],
+            event_type="semantic_asset.version_published",
+            payload={
+                "uid": asset["uid"],
+                "asset_kind": asset["asset_kind"],
+                "version": asset["current_version"],
+            },
+        )
+
+    def map_physical_field(self, payload: Any, *, actor_uid: str):
+        if not isinstance(payload, dict):
+            raise ValueError("field mapping payload must be an object")
+        _reject_secrets(payload)
+        asset_uid = _uuid(payload.get("asset_uid"), "asset_uid")
+        field_name = _text(payload.get("field_name"), "field_name", 200)
+        data_element_uid = _uuid(
+            payload.get("data_element_uid"), "data_element_uid"
+        )
+        source_asset = self.repository.get_active_metadata_asset(asset_uid)
+        if source_asset is None:
+            raise ValueError("active metadata asset was not found")
+        fields = {
+            str(item.get("name"))
+            for item in source_asset.get("snapshot", {}).get("fields", [])
+        }
+        if field_name not in fields:
+            raise ValueError("physical field was not found in active metadata")
+        element = self.repository.get_data_element(data_element_uid)
+        if element is None or element.get("status") != "published":
+            raise ValueError("physical mapping requires a published data element")
+        now = self._now()
+        mapping = {
+            "uid": self.uid_factory(),
+            "asset_uid": asset_uid,
+            "field_name": field_name,
+            "data_element_uid": data_element_uid,
+            "data_element_version": int(element["current_version"]),
+            "owner_uid": _uuid(payload.get("owner_uid"), "owner_uid"),
+            "status": "published",
+            "evidence": deepcopy(payload.get("evidence") or {}),
+            "created_by": _uuid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "updated_at": now,
+        }
+        audit = {
+            "uid": self.uid_factory(),
+            "target_type": "field_mapping",
+            "target_uid": mapping["uid"],
+            "target_version": 1,
+            "action": "mapped",
+            "actor_uid": mapping["created_by"],
+            "detail": {
+                "asset_uid": asset_uid,
+                "field_name": field_name,
+                "data_element_uid": data_element_uid,
+                "data_element_version": mapping["data_element_version"],
+            },
+            "created_at": now,
+        }
+        return self.repository.save_field_mapping(mapping, audit)
+
+    def list_field_mappings(
+        self,
+        *,
+        asset_uid: str | None = None,
+        data_element_uid: str | None = None,
+    ):
+        return self.repository.list_field_mappings(
+            asset_uid=_uuid(asset_uid, "asset_uid") if asset_uid else None,
+            data_element_uid=(
+                _uuid(data_element_uid, "data_element_uid")
+                if data_element_uid
+                else None
+            ),
+        )
+
+
+def _safe(value: Any, key: str = "") -> Any:
+    normalized = key.casefold().replace("-", "_")
+    if any(marker in normalized for marker in SECRET_MARKERS):
+        return "[redacted]"
+    if isinstance(value, dict):
+        return {
+            str(item_key): _safe(item_value, str(item_key))
+            for item_key, item_value in sorted(value.items())
+        }
+    if isinstance(value, list):
+        return [_safe(item) for item in value]
+    return value
+
+
+class SemanticExchange:
+    """Deterministic JSON and controlled RDF/XML export for published semantics."""
+
+    @staticmethod
+    def _published(assets: list[dict[str, Any]]) -> list[dict[str, Any]]:
+        return [
+            _safe(item)
+            for item in assets
+            if item.get("status") == "published"
+        ]
+
+    def export_json(self, assets: list[dict[str, Any]]) -> bytes:
+        payload = {
+            "format": "dataops-semantic-governance-json-v1",
+            "assets": self._published(assets),
+        }
+        return _canonical(payload).encode("utf-8")
+
+    def export_rdf(self, assets: list[dict[str, Any]]) -> bytes:
+        ET.register_namespace("rdf", RDF_NS)
+        ET.register_namespace("dataops", DATAOPS_NS)
+        root = ET.Element(f"{{{RDF_NS}}}RDF")
+        tags = {
+            "business_term": "BusinessTerm",
+            "code_set": "CodeSet",
+            "metric": "MetricDefinition",
+        }
+        for asset in self._published(assets):
+            node = ET.SubElement(root, f"{{{DATAOPS_NS}}}{tags[asset['asset_kind']]}")
+            node.set(f"{{{RDF_NS}}}about", f"urn:dataops:semantic:{asset['uid']}")
+            for tag, value in (
+                ("code", asset["code"]),
+                ("name", asset["name"]),
+                ("version", asset["current_version"]),
+                ("definition", asset["definition"]),
+            ):
+                child = ET.SubElement(node, f"{{{DATAOPS_NS}}}{tag}")
+                child.text = (
+                    _canonical(value)
+                    if isinstance(value, (dict, list))
+                    else str(value)
+                )
+        return ET.tostring(root, encoding="utf-8", xml_declaration=True)

+ 279 - 0
deployment/app/core/data_research/semantic_knowledge.py

@@ -0,0 +1,279 @@
+"""Project published semantic assets into the canonical governance knowledge model."""
+
+from __future__ import annotations
+
+from sqlalchemy import text
+
+from app.core.knowledge.point_builder import build_knowledge_snapshot
+
+OBJECT_TYPES = {
+    "business_term": "BusinessTerm",
+    "code_set": "CodeSet",
+    "metric": "MetricDefinition",
+}
+
+
+class SemanticKnowledgeSyncHandler:
+    def __init__(self, semantic_service, knowledge_sync):
+        self.semantic_service = semantic_service
+        self.knowledge_sync = knowledge_sync
+
+    def handle(self, payload):
+        uid = str(payload.get("uid") or "")
+        kind = str(payload.get("asset_kind") or "")
+        version = int(payload.get("version") or 0)
+        if not uid or kind not in OBJECT_TYPES or version <= 0:
+            raise ValueError("semantic publication event is invalid")
+        asset = self.semantic_service.get_published_version(uid, version)
+        if asset["asset_kind"] != kind:
+            raise ValueError("semantic publication kind does not match")
+        source = {
+            **asset["definition"],
+            "uid": asset["uid"],
+            "version": asset["current_version"],
+            "name": asset.get("name") or asset.get("code") or asset["uid"],
+            "owner": asset.get("owner_uid"),
+            "business_domain_uid": asset.get("business_domain_uid"),
+        }
+        snapshot = build_knowledge_snapshot(OBJECT_TYPES[kind], source)
+        return self.knowledge_sync.sync(snapshot)
+
+
+class PublishedGovernanceKnowledgeHandler:
+    def __init__(
+        self,
+        *,
+        knowledge_sync,
+        standard_loader,
+        ontology_loader,
+    ):
+        self.knowledge_sync = knowledge_sync
+        self.standard_loader = standard_loader
+        self.ontology_loader = ontology_loader
+
+    def _sync(self, object_type, record):
+        if not record or record.get("status") != "published":
+            raise ValueError(
+                f"published {object_type.casefold()} version was not found"
+            )
+        return self.knowledge_sync.sync(
+            build_knowledge_snapshot(object_type, record)
+        )
+
+    def handle_standard(self, payload):
+        version_uid = str(payload.get("version_uid") or "")
+        if not version_uid:
+            raise ValueError("standard publication event is invalid")
+        return self._sync(
+            "DataStandard",
+            self.standard_loader(version_uid),
+        )
+
+    def handle_ontology(self, payload):
+        version_uid = str(payload.get("version_uid") or "")
+        if not version_uid:
+            raise ValueError("ontology publication event is invalid")
+        return self._sync(
+            "Ontology",
+            self.ontology_loader(version_uid),
+        )
+
+
+def _build_knowledge_sync(app, session):
+    if not app.config.get("KNOWLEDGE_ENABLED", True):
+        return None
+    api_key = app.config.get("QWEN_EMBEDDING_API_KEY", "")
+    base_url = app.config.get("QWEN_EMBEDDING_BASE_URL", "")
+    if not api_key or not base_url:
+        return None
+    profile = (
+        session.execute(
+            text(
+                """
+                SELECT id::text, provider, model, dimension
+                FROM public.knowledge_embedding_profiles
+                WHERE status = 'active'
+                """
+            )
+        )
+        .mappings()
+        .one_or_none()
+    )
+    if profile is None:
+        return None
+    model = app.config["QWEN_EMBEDDING_MODEL"]
+    dimension = int(app.config["QWEN_EMBEDDING_DIMENSION"])
+    if (
+        profile["provider"] != "qwen"
+        or profile["model"] != model
+        or int(profile["dimension"]) != dimension
+    ):
+        raise RuntimeError(
+            "active knowledge embedding profile does not match Qwen configuration"
+        )
+
+    from app.core.knowledge.repository import SqlKnowledgeRepository
+    from app.core.knowledge.sync import KnowledgeSyncService
+    from app.services.embedding.qwen import QwenEmbeddingClient
+
+    embedder = QwenEmbeddingClient(
+        api_key=api_key,
+        base_url=base_url,
+        model=model,
+        dimension=dimension,
+    )
+    return KnowledgeSyncService(
+        repository=SqlKnowledgeRepository(
+            session,
+            embedding_profile_id=profile["id"],
+            embedding_profile_key=embedder.profile_key,
+            generation=1,
+            workspace="dataops-governance-g1",
+        ),
+        embedder=embedder,
+    )
+
+
+def _load_standard(session, version_uid):
+    row = (
+        session.execute(
+            text(
+                """
+                SELECT version.id::text AS uid,
+                       version.standard_uid::text AS standard_uid,
+                       version.version_no AS version,
+                       version.status,
+                       standard.name,
+                       standard.owner_uid::text AS owner,
+                       version.standard_spec,
+                       version.scope
+                FROM public.data_standard_versions version
+                JOIN public.data_standards standard
+                  ON standard.standard_uid = version.standard_uid
+                WHERE version.id = CAST(:version_uid AS uuid)
+                """
+            ),
+            {"version_uid": version_uid},
+        )
+        .mappings()
+        .one_or_none()
+    )
+    if row is None:
+        return None
+    spec = dict(row["standard_spec"] or {})
+    scope = dict(row["scope"] or {})
+    return {
+        "uid": str(row["standard_uid"]),
+        "version_uid": str(row["uid"]),
+        "version": int(row["version"]),
+        "status": row["status"],
+        "name": row["name"],
+        "definition": spec.get("description") or row["name"],
+        "owner": row["owner"],
+        "business_domain_uid": scope.get("business_domain_uid"),
+        "rules": [
+            {
+                "uid": item["id"],
+                "name": item["id"],
+                "definition": item["description"],
+            }
+            for item in spec.get("clauses", [])
+        ],
+    }
+
+
+def _load_ontology(session, version_uid):
+    row = (
+        session.execute(
+            text(
+                """
+                SELECT version.uid::text AS version_uid,
+                       ontology.uid::text AS uid,
+                       version.version,
+                       version.status,
+                       ontology.name,
+                       ontology.code,
+                       ontology.owner_uid AS owner,
+                       version.graph_document,
+                       (
+                           SELECT link.domain_uid::text
+                           FROM public.ontology_domain_links link
+                           WHERE link.ontology_uid = ontology.uid
+                           ORDER BY CASE link.role
+                               WHEN 'owner' THEN 0 ELSE 1 END,
+                               link.domain_uid
+                           LIMIT 1
+                       ) AS business_domain_uid
+                FROM public.ontology_versions version
+                JOIN public.ontologies ontology
+                  ON ontology.uid = version.ontology_uid
+                WHERE version.uid = CAST(:version_uid AS uuid)
+                """
+            ),
+            {"version_uid": version_uid},
+        )
+        .mappings()
+        .one_or_none()
+    )
+    if row is None:
+        return None
+    graph = dict(row["graph_document"] or {})
+    return {
+        "uid": str(row["uid"]),
+        "version_uid": str(row["version_uid"]),
+        "version": int(row["version"]),
+        "status": row["status"],
+        "name": row["name"],
+        "definition": f"Ontology {row['code']}",
+        "owner": row["owner"],
+        "business_domain_uid": row["business_domain_uid"],
+        "classes": graph.get("classes", []),
+        "properties": [
+            {
+                **item,
+                "uid": item.get("uid") or item.get("property_uid"),
+            }
+            for item in graph.get("properties", [])
+        ],
+    }
+
+
+def build_semantic_knowledge_handler(app, session):
+    """Build the canonical semantic handler when embeddings are configured."""
+    configured = app.extensions.get("semantic_knowledge_handler")
+    if configured is not None:
+        return configured
+    knowledge = _build_knowledge_sync(app, session)
+    if knowledge is None:
+        return None
+    from app.core.data_research.semantic_governance import (
+        SemanticGovernanceService,
+    )
+    from app.core.data_research.semantic_repository import (
+        SqlAlchemySemanticGovernanceRepository,
+    )
+
+    semantic = SemanticGovernanceService(
+        SqlAlchemySemanticGovernanceRepository(session)
+    )
+    return SemanticKnowledgeSyncHandler(semantic, knowledge)
+
+
+def build_governance_knowledge_handlers(app, session):
+    configured = app.extensions.get("governance_knowledge_handlers")
+    if configured is not None:
+        return dict(configured)
+    semantic = build_semantic_knowledge_handler(app, session)
+    if semantic is None:
+        return {}
+    knowledge = semantic.knowledge_sync
+    published = PublishedGovernanceKnowledgeHandler(
+        knowledge_sync=knowledge,
+        standard_loader=lambda uid: _load_standard(session, uid),
+        ontology_loader=lambda uid: _load_ontology(session, uid),
+    )
+    return {
+        "semantic_asset.version_published": semantic.handle,
+        "data_standard.version_published": published.handle_standard,
+        "ontology.version_published": published.handle_ontology,
+    }

+ 516 - 0
deployment/app/core/data_research/semantic_repository.py

@@ -0,0 +1,516 @@
+"""PostgreSQL repository for generic semantic governance."""
+
+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(row):
+    if row is None:
+        return None
+    result = dict(row)
+    for key, value in tuple(result.items()):
+        if isinstance(value, uuid.UUID):
+            result[key] = str(value)
+        elif hasattr(value, "isoformat"):
+            result[key] = value.isoformat()
+        elif isinstance(value, dict):
+            result[key] = dict(value)
+    return result
+
+
+class SqlAlchemySemanticGovernanceRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def get_by_code(self, asset_kind, code):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, asset_kind, code, name, owner_uid,
+                           business_domain_uid, status, current_version,
+                           created_by, created_at, updated_at
+                    FROM public.semantic_assets
+                    WHERE asset_kind = :asset_kind AND code = :code
+                    """
+                ),
+                {"asset_kind": asset_kind, "code": code},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def get(self, uid):
+        row = _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT asset.uid, asset.asset_kind, asset.code, asset.name,
+                           asset.owner_uid, asset.business_domain_uid,
+                           asset.status, asset.current_version,
+                           asset.created_by, asset.created_at, asset.updated_at,
+                           version.definition
+                    FROM public.semantic_assets asset
+                    JOIN public.semantic_asset_versions version
+                      ON version.asset_uid = asset.uid
+                     AND version.version = asset.current_version
+                    WHERE asset.uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return row
+
+    def list(self, *, asset_kind=None, status=None):
+        conditions = []
+        params = {}
+        if asset_kind:
+            conditions.append("asset.asset_kind = :asset_kind")
+            params["asset_kind"] = asset_kind
+        if status:
+            conditions.append("asset.status = :status")
+            params["status"] = status
+        where = "WHERE " + " AND ".join(conditions) if conditions else ""
+        statement = (
+            "SELECT asset.uid, asset.asset_kind, asset.code, asset.name, "
+            "asset.owner_uid, asset.business_domain_uid, asset.status, "
+            "asset.current_version, asset.created_by, asset.created_at, "
+            "asset.updated_at, version.definition "
+            "FROM public.semantic_assets asset "
+            "JOIN public.semantic_asset_versions version "
+            "ON version.asset_uid = asset.uid "
+            "AND version.version = asset.current_version "
+            f"{where} ORDER BY asset.asset_kind, asset.code"
+        )
+        return [
+            _row(item)
+            for item in self.session.execute(text(statement), params)
+            .mappings()
+            .all()
+        ]
+
+    def save_new(self, asset, version, audit, links):
+        self._insert_asset(asset)
+        self._insert_version(version)
+        self._insert_links(links)
+        self._insert_audit(audit)
+        return self.get(asset["uid"])
+
+    def _insert_asset(self, asset):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.semantic_assets (
+                    uid, asset_kind, code, name, owner_uid,
+                    business_domain_uid, status, current_version,
+                    created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :asset_kind, :code, :name,
+                    CAST(:owner_uid AS uuid),
+                    CAST(:business_domain_uid AS uuid), :status,
+                    :current_version, CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                """
+            ),
+            asset,
+        )
+
+    def _insert_version(self, version):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.semantic_asset_versions (
+                    uid, asset_uid, version, status, content_hash,
+                    definition, change_reason, created_by,
+                    rollback_from_version, created_at, published_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:asset_uid AS uuid),
+                    :version, :status, :content_hash,
+                    CAST(:definition AS jsonb), :change_reason,
+                    CAST(:created_by AS uuid), :rollback_from_version,
+                    CAST(:created_at AS timestamptz),
+                    CAST(:published_at AS timestamptz)
+                )
+                """
+            ),
+            {**version, "definition": _json(version["definition"])},
+        )
+
+    def _insert_links(self, links):
+        for link in links:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.semantic_asset_links (
+                        uid, asset_uid, version, link_type, target_type,
+                        target_uid, target_key, metadata
+                    ) VALUES (
+                        gen_random_uuid(), CAST(:asset_uid AS uuid), :version,
+                        :link_type, :target_type, :target_uid, :target_key,
+                        CAST(:metadata AS jsonb)
+                    )
+                    """
+                ),
+                {**link, "metadata": _json(link["metadata"])},
+            )
+
+    def _insert_audit(self, audit):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.semantic_publication_audits (
+                    uid, target_type, target_uid, target_version,
+                    action, actor_uid, detail, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), :target_type,
+                    CAST(:target_uid AS uuid), :target_version,
+                    :action, CAST(:actor_uid AS uuid),
+                    CAST(:detail AS jsonb),
+                    CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {**audit, "detail": _json(audit["detail"])},
+        )
+
+    def list_versions(self, uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, asset_uid, version, status, content_hash,
+                           definition, change_reason, created_by,
+                           rollback_from_version, created_at, published_at
+                    FROM public.semantic_asset_versions
+                    WHERE asset_uid = CAST(:uid AS uuid)
+                    ORDER BY version
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_reviews(self, uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, asset_uid, version, decision, reason,
+                           reviewer_uid, created_at
+                    FROM public.semantic_asset_reviews
+                    WHERE asset_uid = CAST(:uid AS uuid)
+                    ORDER BY version, created_at
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_audits(self, uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, target_type, target_uid, target_version,
+                           action, actor_uid, detail, created_at
+                    FROM public.semantic_publication_audits
+                    WHERE target_uid = CAST(:uid AS uuid)
+                    ORDER BY created_at, uid
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def transition(self, asset, version, audit, review=None):
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.semantic_assets
+                SET status = :status,
+                    name = :name,
+                    owner_uid = CAST(:owner_uid AS uuid),
+                    business_domain_uid = CAST(:business_domain_uid AS uuid),
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :current_version
+                """
+            ),
+            asset,
+        )
+        if result.rowcount != 1:
+            raise RuntimeError("semantic asset version conflict")
+        self.session.execute(
+            text(
+                """
+                UPDATE public.semantic_asset_versions
+                SET status = :status,
+                    published_at = CAST(:published_at AS timestamptz)
+                WHERE asset_uid = CAST(:asset_uid AS uuid)
+                  AND version = :version
+                """
+            ),
+            version,
+        )
+        if review is not None:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.semantic_asset_reviews (
+                        uid, asset_uid, version, decision, reason,
+                        reviewer_uid, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:asset_uid AS uuid),
+                        :version, :decision, :reason,
+                        CAST(:reviewer_uid AS uuid),
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                review,
+            )
+        self._insert_audit(audit)
+        return self.get(asset["uid"])
+
+    def append_version(self, asset, version, audit, links):
+        if version["status"] == "published":
+            self.session.execute(
+                text(
+                    """
+                    UPDATE public.semantic_asset_versions
+                    SET status = 'superseded'
+                    WHERE asset_uid = CAST(:asset_uid AS uuid)
+                      AND status = 'published'
+                    """
+                ),
+                {"asset_uid": asset["uid"]},
+            )
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.semantic_assets
+                SET name = :name,
+                    owner_uid = CAST(:owner_uid AS uuid),
+                    business_domain_uid = CAST(:business_domain_uid AS uuid),
+                    status = :status,
+                    current_version = :current_version,
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :expected_version
+                """
+            ),
+            {**asset, "expected_version": int(asset["current_version"]) - 1},
+        )
+        if result.rowcount != 1:
+            raise RuntimeError("semantic asset version conflict")
+        self._insert_version(version)
+        self._insert_links(links)
+        self._insert_audit(audit)
+        return self.get(asset["uid"])
+
+    def active_assets_exist(self, uids):
+        if not uids:
+            return True
+        found = self.session.execute(
+            text(
+                """
+                SELECT count(*)
+                FROM public.active_metadata_assets
+                WHERE uid = ANY(CAST(:uids AS uuid[]))
+                  AND lifecycle_status = 'active'
+                """
+            ),
+            {"uids": uids},
+        ).scalar_one()
+        return int(found) == len(set(uids))
+
+    def published_data_elements_exist(self, uids):
+        if not uids:
+            return True
+        found = self.session.execute(
+            text(
+                """
+                SELECT count(*) FROM public.data_elements
+                WHERE uid = ANY(CAST(:uids AS uuid[]))
+                  AND status = 'published'
+                """
+            ),
+            {"uids": uids},
+        ).scalar_one()
+        return int(found) == len(set(uids))
+
+    def published_standard_versions_exist(self, uids):
+        if not uids:
+            return True
+        found = self.session.execute(
+            text(
+                """
+                SELECT count(*) FROM public.data_standard_versions
+                WHERE id = ANY(CAST(:uids AS uuid[]))
+                  AND status = 'published'
+                """
+            ),
+            {"uids": uids},
+        ).scalar_one()
+        return int(found) == len(set(uids))
+
+    def published_code_exists(self, code_set_uid, code):
+        return bool(
+            self.session.execute(
+                text(
+                    """
+                    SELECT 1
+                    FROM public.semantic_assets asset
+                    JOIN public.semantic_asset_versions version
+                      ON version.asset_uid = asset.uid
+                     AND version.version = asset.current_version
+                    CROSS JOIN LATERAL
+                      jsonb_array_elements(version.definition->'values') value
+                    WHERE asset.uid = CAST(:uid AS uuid)
+                      AND asset.asset_kind = 'code_set'
+                      AND asset.status = 'published'
+                      AND value->>'code' = :code
+                    LIMIT 1
+                    """
+                ),
+                {"uid": code_set_uid, "code": code},
+            ).scalar_one_or_none()
+        )
+
+    def get_active_metadata_asset(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, snapshot
+                    FROM public.active_metadata_assets
+                    WHERE uid = CAST(:uid AS uuid)
+                      AND lifecycle_status = 'active'
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def get_data_element(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, status, current_version
+                    FROM public.data_elements
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def save_field_mapping(self, mapping, audit):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.data_element_field_mappings (
+                    uid, asset_uid, field_name, data_element_uid,
+                    data_element_version, owner_uid, status, evidence,
+                    created_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:asset_uid AS uuid), :field_name,
+                    CAST(:data_element_uid AS uuid), :data_element_version,
+                    CAST(:owner_uid AS uuid), :status,
+                    CAST(:evidence AS jsonb), CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                ON CONFLICT (asset_uid, field_name, data_element_uid)
+                DO NOTHING
+                """
+            ),
+            {**mapping, "evidence": _json(mapping["evidence"])},
+        )
+        found = self._field_mapping(
+            mapping["asset_uid"],
+            mapping["field_name"],
+            mapping["data_element_uid"],
+        )
+        if found["uid"] == mapping["uid"]:
+            self._insert_audit(audit)
+        return found
+
+    def _field_mapping(self, asset_uid, field_name, data_element_uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, asset_uid, field_name, data_element_uid,
+                           data_element_version, owner_uid, status, evidence,
+                           created_by, created_at, updated_at
+                    FROM public.data_element_field_mappings
+                    WHERE asset_uid = CAST(:asset_uid AS uuid)
+                      AND field_name = :field_name
+                      AND data_element_uid = CAST(:data_element_uid AS uuid)
+                    """
+                ),
+                {
+                    "asset_uid": asset_uid,
+                    "field_name": field_name,
+                    "data_element_uid": data_element_uid,
+                },
+            )
+            .mappings()
+            .one()
+        )
+
+    def list_field_mappings(self, *, asset_uid=None, data_element_uid=None):
+        conditions = []
+        params = {}
+        if asset_uid:
+            conditions.append("asset_uid = CAST(:asset_uid AS uuid)")
+            params["asset_uid"] = asset_uid
+        if data_element_uid:
+            conditions.append(
+                "data_element_uid = CAST(:data_element_uid AS uuid)"
+            )
+            params["data_element_uid"] = data_element_uid
+        where = "WHERE " + " AND ".join(conditions) if conditions else ""
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    "SELECT uid, asset_uid, field_name, data_element_uid, "
+                    "data_element_version, owner_uid, status, evidence, "
+                    "created_by, created_at, updated_at "
+                    f"FROM public.data_element_field_mappings {where} "
+                    "ORDER BY created_at, uid"
+                ),
+                params,
+            )
+            .mappings()
+            .all()
+        ]

+ 38 - 1
deployment/app/core/data_rules/repository.py

@@ -31,6 +31,7 @@ from app.core.data_rules.execution_contracts import (
     validate_dataset_binding,
     validate_schema_snapshot,
 )
+from app.core.events.outbox import enqueue_outbox
 
 RULE_CATEGORIES = {
     "general",
@@ -2849,7 +2850,43 @@ class DataRuleRepository:
             .one_or_none()
         )
         if row is not None:
-            return dict(row)
+            published = dict(row)
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.semantic_publication_audits (
+                        uid, target_type, target_uid, target_version,
+                        action, actor_uid, detail
+                    ) VALUES (
+                        CAST(:uid AS uuid), 'data_standard',
+                        CAST(:target_uid AS uuid), :target_version,
+                        'published', CAST(:actor_uid AS uuid),
+                        CAST(:detail AS jsonb)
+                    )
+                    """
+                ),
+                {
+                    "uid": new_governance_uid(),
+                    "target_uid": published["standard_uid"],
+                    "target_version": int(published["version_no"]),
+                    "actor_uid": published_by,
+                    "detail": _json(
+                        {"version_uid": published["id"]}
+                    ),
+                },
+            )
+            enqueue_outbox(
+                self.session,
+                aggregate_type="data_standard",
+                aggregate_id=published["standard_uid"],
+                event_type="data_standard.version_published",
+                payload={
+                    "standard_uid": published["standard_uid"],
+                    "version_uid": published["id"],
+                    "version": int(published["version_no"]),
+                },
+            )
+            return published
         status = self.session.execute(
             text(
                 "SELECT status /* current_version_status */ "

+ 17 - 4
deployment/app/core/events/outbox.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from datetime import datetime, timezone
+from datetime import UTC, datetime
 from typing import Any
 
 from sqlalchemy import text
@@ -44,7 +44,13 @@ def enqueue_outbox(
     return event_id
 
 
-def claim_outbox(session: Any, limit: int = 50) -> list[ClaimedEvent]:
+def claim_outbox(
+    session: Any,
+    limit: int = 50,
+    event_types: list[str] | None = None,
+) -> list[ClaimedEvent]:
+    if event_types is not None and not event_types:
+        return []
     rows = session.execute(
         text(
             """
@@ -52,6 +58,10 @@ def claim_outbox(session: Any, limit: int = 50) -> list[ClaimedEvent]:
                 SELECT event_id
                 FROM public.outbox_events
                 WHERE status = 'pending' AND available_at <= CURRENT_TIMESTAMP
+                  AND (
+                    CAST(:event_types AS text[]) IS NULL
+                    OR event_type = ANY(CAST(:event_types AS text[]))
+                  )
                 ORDER BY available_at, created_at
                 FOR UPDATE SKIP LOCKED
                 LIMIT :limit
@@ -64,7 +74,10 @@ def claim_outbox(session: Any, limit: int = 50) -> list[ClaimedEvent]:
                       event.payload, event.attempts
             """
         ),
-        {"limit": int(limit)},
+        {
+            "limit": int(limit),
+            "event_types": event_types,
+        },
     )
     return [
         ClaimedEvent(
@@ -151,7 +164,7 @@ def outbox_health(session: Any) -> dict[str, Any]:
         "backlog": int(row[0] or 0),
         "failed": int(row[1] or 0),
         "oldest_age_seconds": float(row[2] or 0),
-        "checked_at": datetime.now(timezone.utc).isoformat(),
+        "checked_at": datetime.now(UTC).isoformat(),
     }
 
 

+ 24 - 2
deployment/app/core/knowledge/point_builder.py

@@ -24,11 +24,15 @@ SECRET_MARKERS = (
 VOLATILE_KEYS = {"created_at", "updated_at"}
 SUPPORTED_TYPES = {
     "BusinessDomain",
+    "BusinessTerm",
+    "CodeSet",
     "DataFlow",
     "DataMeta",
     "DataStandard",
     "Label",
     "DataLabel",
+    "MetricDefinition",
+    "Ontology",
 }
 
 
@@ -146,6 +150,8 @@ def build_knowledge_snapshot(
         ("purpose", ("purpose", "script_requirement")),
         ("owner", ("owner", "owner_name")),
         ("data_type", ("data_type", "type")),
+        ("formula", ("formula",)),
+        ("unit", ("unit",)),
     )
     for semantic_path, keys in scalar_specs:
         value = _first(source, *keys)
@@ -197,9 +203,25 @@ def build_knowledge_snapshot(
             )
         )
 
-    for collection_name in ("fields", "rules"):
+    for collection_name in (
+        "fields",
+        "rules",
+        "values",
+        "dimensions",
+        "classes",
+        "properties",
+    ):
         for item in _iter_dicts(source.get(collection_name)):
-            item_uid = _require_child_uid(item, collection_name[:-1])
+            item_uid = (
+                _require_child_uid(item, collection_name[:-1])
+                if collection_name
+                in {"fields", "rules", "classes", "properties"}
+                else str(
+                    item.get("uid")
+                    or item.get("code")
+                    or _require_child_uid(item, collection_name[:-1])
+                )
+            )
             for semantic_name, keys in (
                 ("name", ("name_zh", "name", "name_en")),
                 ("definition", ("definition", "description")),

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

@@ -52,6 +52,9 @@ DOMAIN_TEMPLATES_MANAGE = "domain-templates:manage"
 ACTIVE_METADATA_READ = "active-metadata:read"
 ACTIVE_METADATA_OPERATE = "active-metadata:operate"
 ACTIVE_METADATA_MANAGE = "active-metadata:manage"
+SEMANTIC_GOVERNANCE_EDIT = "semantic-governance:edit"
+SEMANTIC_GOVERNANCE_REVIEW = "semantic-governance:review"
+SEMANTIC_GOVERNANCE_PUBLISH = "semantic-governance:publish"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -87,6 +90,7 @@ ROLE_PERMISSIONS = {
             DOMAIN_TEMPLATES_PREVIEW,
             ACTIVE_METADATA_READ,
             ACTIVE_METADATA_OPERATE,
+            SEMANTIC_GOVERNANCE_EDIT,
         }
     ),
     "admin": frozenset(
@@ -136,6 +140,9 @@ ROLE_PERMISSIONS = {
             ACTIVE_METADATA_READ,
             ACTIVE_METADATA_OPERATE,
             ACTIVE_METADATA_MANAGE,
+            SEMANTIC_GOVERNANCE_EDIT,
+            SEMANTIC_GOVERNANCE_REVIEW,
+            SEMANTIC_GOVERNANCE_PUBLISH,
         }
     ),
 }
@@ -214,6 +221,14 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         return (MANAGE_USERS,)
     if path.startswith("/api/development/v1/governance-metrics"):
         return (READ_GOVERNANCE,)
+    if path.startswith("/api/development/v1/semantic"):
+        if method == "GET":
+            return (READ_GOVERNANCE,)
+        if path.endswith("/review"):
+            return (SEMANTIC_GOVERNANCE_REVIEW,)
+        if path.endswith("/publish") or path.endswith("/rollback"):
+            return (SEMANTIC_GOVERNANCE_PUBLISH,)
+        return (SEMANTIC_GOVERNANCE_EDIT,)
     if path.startswith("/api/development/v1/device-assets"):
         if method == "GET":
             return (READ_GOVERNANCE,)

+ 14 - 0
deployment/app/models/__init__.py

@@ -34,6 +34,14 @@ from app.models.governance_template import (
     GovernanceObjectType,
 )
 from app.models.metadata_review import MetadataReviewRecord, MetadataVersionHistory
+from app.models.semantic_governance import (
+    DataElementFieldMapping,
+    SemanticAsset,
+    SemanticAssetLink,
+    SemanticAssetReview,
+    SemanticAssetVersion,
+    SemanticPublicationAudit,
+)
 
 __all__ = [
     "ActiveMetadataPlan",
@@ -49,6 +57,12 @@ __all__ = [
     "DataProduct",
     "MetadataReviewRecord",
     "MetadataVersionHistory",
+    "SemanticAsset",
+    "SemanticAssetVersion",
+    "SemanticAssetReview",
+    "SemanticAssetLink",
+    "DataElementFieldMapping",
+    "SemanticPublicationAudit",
     "GovernanceDomainTemplate",
     "GovernanceDomainTemplateVersion",
     "GovernanceObjectType",

+ 102 - 0
deployment/app/models/semantic_governance.py

@@ -0,0 +1,102 @@
+"""SQLAlchemy mappings for generic semantic governance."""
+
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+
+from app import db
+from app.core.common.identifiers import new_governance_uid
+
+
+class SemanticAsset(db.Model):
+    __tablename__ = "semantic_assets"
+    __table_args__ = (
+        db.UniqueConstraint("asset_kind", "code"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_kind = db.Column(db.String(30), nullable=False)
+    code = db.Column(db.String(120), nullable=False)
+    name = db.Column(db.String(300), nullable=False)
+    owner_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    business_domain_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    current_version = db.Column(db.Integer, nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class SemanticAssetVersion(db.Model):
+    __tablename__ = "semantic_asset_versions"
+    __table_args__ = (
+        db.UniqueConstraint("asset_uid", "version"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    version = db.Column(db.Integer, nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    content_hash = db.Column(db.String(64), nullable=False)
+    definition = db.Column(JSONB, nullable=False)
+    change_reason = db.Column(db.String(1000), nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+    rollback_from_version = db.Column(db.Integer)
+
+
+class SemanticAssetReview(db.Model):
+    __tablename__ = "semantic_asset_reviews"
+    __table_args__ = (
+        db.UniqueConstraint("asset_uid", "version"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    version = db.Column(db.Integer, nullable=False)
+    decision = db.Column(db.String(20), nullable=False)
+    reason = db.Column(db.String(1000), nullable=False)
+    reviewer_uid = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class SemanticAssetLink(db.Model):
+    __tablename__ = "semantic_asset_links"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    version = db.Column(db.Integer, nullable=False)
+    link_type = db.Column(db.String(30), nullable=False)
+    target_type = db.Column(db.String(60), nullable=False)
+    target_uid = db.Column(db.String(200), nullable=False)
+    target_key = db.Column(db.String(200))
+    metadata_json = db.Column("metadata", JSONB, nullable=False)
+
+
+class DataElementFieldMapping(db.Model):
+    __tablename__ = "data_element_field_mappings"
+    __table_args__ = (
+        db.UniqueConstraint("asset_uid", "field_name", "data_element_uid"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    field_name = db.Column(db.String(200), nullable=False)
+    data_element_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    data_element_version = db.Column(db.Integer, nullable=False)
+    owner_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    evidence = db.Column(JSONB, nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class SemanticPublicationAudit(db.Model):
+    __tablename__ = "semantic_publication_audits"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    target_type = db.Column(db.String(40), nullable=False)
+    target_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    target_version = db.Column(db.Integer, nullable=False)
+    action = db.Column(db.String(40), nullable=False)
+    actor_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    detail = db.Column(JSONB, nullable=False)

+ 6 - 1
deployment/app/services/embedding/qwen.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
+
 import hashlib
-from typing import Any
+
 import requests
 
 
@@ -15,6 +16,10 @@ class QwenEmbeddingClient:
         self.api_key, self.base_url, self.model = api_key, base_url.rstrip("/"), model
         self.dimension, self.timeout = dimension, timeout
 
+    @property
+    def profile_key(self) -> str:
+        return f"qwen:{self.model}:{self.dimension}"
+
     def cache_key(self, text: str) -> str:
         return hashlib.sha256(f"{self.model}:{self.dimension}:{text}".encode()).hexdigest()
 

+ 5 - 5
docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md

@@ -245,11 +245,11 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 
 **主要工作:**
 
-- [ ] 收口 StandardVersion 发布门禁和物理字段到数据元素映射。
-- [ ] 建设业务术语、别名、责任人、关联资产和版本。
-- [ ] 建设通用代码集及引用完整性校验。
-- [ ] 建设指标定义、维度、计算口径、责任人和关联资产,不建设 BI 计算引擎。
-- [ ] 支持 JSON/OWL 交换并补齐受控 RDF 导出,发布后同步治理知识库。
+- [x] 收口 StandardVersion 发布门禁和物理字段到数据元素映射。
+- [x] 建设业务术语、别名、责任人、关联资产和版本。
+- [x] 建设通用代码集及引用完整性校验。
+- [x] 建设指标定义、维度、计算口径、责任人和关联资产,不建设 BI 计算引擎。
+- [x] 支持 JSON/OWL 交换并补齐受控 RDF 导出,发布后同步治理知识库。
 
 **主要文件区域:**
 

+ 7 - 7
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -557,19 +557,19 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | 模块编号 | 模块分级 | 功能项 | 成熟度 |
 |---|---|---|---|
 | SEM-01 | 数据标准 / 标准管理 | 数据标准新增、详情、更新、列表和图谱 | 已建设 |
-| SEM-02 | 数据标准 / 标准版本 | 不可变 StandardVersion、发布和规则绑定 | 工程完成,受门禁 |
-| SEM-03 | 业务语义 / 数据元素 | 物理字段与逻辑数据元素分层、版本和映射 | 隔离分支完成,待合入 |
-| SEM-04 | 业务语义 / 业务术语 | 术语、定义、别名、缩写、责任人和关联资产 | 规划中 |
+| SEM-02 | 数据标准 / 标准版本 | 不可变 StandardVersion、发布和规则绑定 | 工程完成,受门禁;P2-WP03 已复核编辑/发布角色分离并接入通用语义引用 |
+| SEM-03 | 业务语义 / 数据元素 | 物理字段与逻辑数据元素分层、版本和映射 | 工程完成,待企业字段映射验收 |
+| SEM-04 | 业务语义 / 业务术语 | 术语、定义、别名、缩写、责任人和关联资产 | 工程完成,待企业术语与责任人验收 |
 | SEM-05 | 业务语义 / 中英文语义 | 中文首期、英文二期、双语名称和别名检索 | 规划中 |
-| SEM-06 | 业务语义 / 代码集 | 代码、枚举、状态和参考数据的统一管理 | 规划中 |
-| SEM-07 | 业务语义 / 指标口径 | 指标定义、维度、计算口径、责任人和关联报表 | 规划中 |
+| SEM-06 | 业务语义 / 代码集 | 代码、枚举、状态和参考数据的统一管理 | 工程完成,待企业参考数据验收 |
+| SEM-07 | 业务语义 / 指标口径 | 指标定义、维度、计算口径、责任人和关联报表 | 工程完成,待企业口径验收;不包含 BI 计算引擎 |
 | SEM-08 | 本体治理 / 本体中心 | 本体列表、业务域关系、状态和负责人 | 已建设 |
 | SEM-09 | 本体治理 / 本体工作台 | 类、属性、关系、约束和映射的图形/结构化编辑 | 已建设 |
 | SEM-10 | 本体治理 / 版本发布 | 草稿、ETag、校验、版本、差异、发布和回滚 | 已建设 |
 | SEM-11 | 本体治理 / 动态建议 | AI 生成概念、关系、约束和映射建议 | 部分建设;建议链已形成,真实模型验收待完成 |
 | SEM-12 | 本体治理 / 建议评审 | 接受、拒绝、修改后接受和证据追溯 | 已建设 |
 | SEM-13 | 本体治理 / 跨域本体 | 一个本体服务多个业务域及 owner/contributor/consumer 关系 | 已建设 |
-| SEM-14 | 本体治理 / 交换格式 | JSON、OWL 导入导出和后续 RDF 支持 | 部分建设;JSON/OWL 已形成,RDF 待建设 |
+| SEM-14 | 本体治理 / 交换格式 | JSON、OWL 导入导出和后续 RDF 支持 | 工程完成;本体 JSON/OWL 与通用语义受控 RDF 已形成,待企业交换样本验收 |
 | SEM-15 | 实体治理 / 实体解析 | 跨系统实体匹配、候选、置信度和解释 | 工程完成,待企业数据与阈值验收 |
 | SEM-16 | 实体治理 / 自动合并 | 高置信度自动映射/合并,中低置信度人工审核 | 部分建设;确定性规则自动合并默认关闭且受门禁,AI 候选保持人工审核 |
 | SEM-17 | 实体治理 / 合并回滚 | 合并前快照、撤销、冲突和下游影响 | 工程完成,待企业回滚场景验收 |
@@ -577,7 +577,7 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | SEM-19 | 设备语义 / 设备标识 | 建立平台设备 UID 并映射各源系统编码 | 工程完成,待企业数据验收 |
 | SEM-20 | 设备语义 / 故障分类 | 整合各系统故障、原因和措施代码 | 工程完成,待企业代码集验收 |
 | SEM-21 | 设备语义 / AI 辅助代码集 | AI 聚类和映射建议,设备资产管理员最终审批 | 部分建设;证据、置信度和审批链已形成,受治理模型与企业证据集待验收 |
-| SEM-22 | 语义服务 / 知识同步 | 已发布术语、标准和本体同步治理知识库 | 部分建设 |
+| SEM-22 | 语义服务 / 知识同步 | 已发布术语、标准和本体同步治理知识库 | 工程完成,受知识 embedding 配置门禁;未配置时发布事件保持待处理 |
 
 ### 12.6 数据质量与可观测性
 

+ 28 - 0
docs/architecture/DATA_MODEL.md

@@ -305,6 +305,33 @@ PostgreSQL。数据库目录采集产生新快照后,在同一事务中投影
 PostgreSQL;用户纠错也只形成待责任人处置的版本化记录。后续发布仍需走既有评审和发布
 门禁。SQL 字段血缘当前采用保守解析:无法可靠解析时失败关闭并保留原因,不猜测字段关系。
 
+## 4.5 P2-WP03 通用术语与标准
+
+P2-WP03 复用现有不可变 `DataStandardVersion`、数据元素和本体服务,不复制数据标准服务,
+新增业务术语、通用代码集和指标定义三类通用语义对象。三类对象统一采用
+`draft → in_review → approved → published` 生命周期;创建者不得审批自己的版本,发布和
+回滚只允许管理员。修订和回滚均追加新版本,旧发布版本标记为 `superseded`,审计不覆盖。
+
+| 数据对象 | 作用 | 关键约束 |
+|---|---|---|
+| `semantic_assets` | 术语、代码集和指标当前态 | 类型与编码唯一,显式责任人和业务域,乐观版本控制 |
+| `semantic_asset_versions` | 不可变语义版本 | canonical JSON 哈希;回滚保存目标版本号 |
+| `semantic_asset_reviews` | 独立审批记录 | 创建者与审批者分离,审批理由必填 |
+| `semantic_asset_links` | 资产、数据元素、标准版本和代码值引用 | 只允许引用活动资产、已发布数据元素/标准/代码值 |
+| `data_element_field_mappings` | 物理字段到逻辑数据元素映射 | 物理字段必须来自活动主动元数据,数据元素必须已发布 |
+| `semantic_publication_audits` | 发布链审计 | 创建、修订、提交、审批、发布、回滚和映射均追加记录 |
+
+业务术语保存定义、别名、责任人和关联对象;代码集校验代码唯一、父代码完整性和跨对象引用;
+指标只保存定义、公式、单位、维度、责任人和关联资产,拒绝 SQL、脚本、查询或运行时字段,
+不提供 BI 计算引擎。语义对象提供安全 JSON 和受控 RDF/XML 导出;本体继续使用既有
+JSON/OWL 交换接口,不把普通语义 JSON 冒充 OWL。
+
+语义对象、数据标准和本体发布分别写入 `semantic_asset.version_published`、
+`data_standard.version_published` 和 `ontology.version_published` outbox 事件。默认消费者
+只领取这三类已注册事件,避免误消费其他业务事件;只有在知识库启用、Qwen 凭据齐备且活动
+embedding profile 与模型、维度一致时才同步 PostgreSQL canonical 治理知识。配置未就绪时
+事件保留为 pending,不跳过授权、不降级为未受治理向量,也不让 LightRAG 旁路成为源真相。
+
 ## 5. 所有权与删除规则
 
 - PostgreSQL 是身份、权限、映射、任务状态、布局和一致性事件的源真相。
@@ -323,6 +350,7 @@ PostgreSQL;用户纠错也只形成待责任人处置的版本化记录。后
 - 审计中心只读取六类现有权威记录的安全投影;`governance_audit_seals` 只追加封存摘要和签名,不接收原始问题、凭据、来源配置、自由文本备注或证据正文。
 - 领域模板、模板版本、通用对象类型和导入审计以 PostgreSQL 为源真相;模板只描述对象契约,不替代设备台账或复制第二套资产服务。模板回滚追加新版本,被移除对象类型只退役、不删除。
 - 主动元数据计划、批次、资产当前态、不可变版本、变化候选、字段血缘、健康信号和纠错审计以 PostgreSQL 为源真相;现有目录快照继续作为批次输入证据。删除只形成候选,解析失败保留原因,发现或纠错不能绕过既有发布门禁覆盖 Neo4j 已发布元数据。
+- 业务术语、通用代码集、指标口径、物理字段映射、不可变版本、独立审批和发布审计以 PostgreSQL 为源真相;标准版本继续复用既有不可变发布门禁。指标口径不是执行计划,发布后通过 outbox 同步 canonical 治理知识,知识同步配置未就绪时事件不得丢失。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 368 - 1
docs/architecture/OPENAPI.yaml

@@ -3,7 +3,7 @@ info:
   title: "DataOps Platform API(当前代码基线)"
   version: "2026-07-16"
   description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"
-x-route-count: 236
+x-route-count: 250
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -3622,6 +3622,373 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-assets":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_semantic_assets_get
+      summary: "list semantic assets"
+      x-source: "app/api/data_development/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [data_development]
+      operationId: data_development_create_semantic_asset_post
+      summary: "create semantic asset"
+      x-source: "app/api/data_development/routes.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-assets/export":
+    get:
+      tags: [data_development]
+      operationId: data_development_export_semantic_assets_get
+      summary: "export semantic assets"
+      x-source: "app/api/data_development/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-assets/{asset_uid}":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_semantic_asset_get
+      summary: "get semantic asset"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: asset_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-assets/{asset_uid}/audits":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_semantic_asset_audits_get
+      summary: "list semantic asset audits"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: asset_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-assets/{asset_uid}/publish":
+    post:
+      tags: [data_development]
+      operationId: data_development_publish_semantic_asset_post
+      summary: "publish semantic asset"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: asset_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-assets/{asset_uid}/review":
+    post:
+      tags: [data_development]
+      operationId: data_development_review_semantic_asset_post
+      summary: "review semantic asset"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: asset_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-assets/{asset_uid}/reviews":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_semantic_asset_reviews_get
+      summary: "list semantic asset reviews"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: asset_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-assets/{asset_uid}/revisions":
+    post:
+      tags: [data_development]
+      operationId: data_development_revise_semantic_asset_post
+      summary: "revise semantic asset"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: asset_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-assets/{asset_uid}/rollback":
+    post:
+      tags: [data_development]
+      operationId: data_development_rollback_semantic_asset_post
+      summary: "rollback semantic asset"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: asset_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-assets/{asset_uid}/submit":
+    post:
+      tags: [data_development]
+      operationId: data_development_submit_semantic_asset_post
+      summary: "submit semantic asset"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: asset_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-assets/{asset_uid}/versions":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_semantic_asset_versions_get
+      summary: "list semantic asset versions"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: asset_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/semantic-field-mappings":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_semantic_field_mappings_get
+      summary: "list semantic field mappings"
+      x-source: "app/api/data_development/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [data_development]
+      operationId: data_development_create_semantic_field_mapping_post
+      summary: "create semantic field mapping"
+      x-source: "app/api/data_development/routes.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/development/v1/semantic/properties/{property_uid}":
     get:
       tags: [data_development]

+ 68 - 0
docs/validation/P2_WP03_SEMANTIC_GOVERNANCE_EVIDENCE.md

@@ -0,0 +1,68 @@
+# P2-WP03 通用术语与标准验证记录
+
+## 1. 工程范围
+
+P2-WP03 在现有 `DataStandardVersion`、数据元素、本体和治理知识能力上补齐跨业务域语义链:
+
+1. 业务术语的定义、别名、责任人、关联资产、标准、数据元素和不可变版本;
+2. 通用代码集、父子代码完整性和跨对象代码引用校验;
+3. 指标定义、公式、单位、维度、责任人和关联资产,但不提供 BI 计算或分析执行;
+4. 活动元数据物理字段到已发布数据元素的受控映射;
+5. 草稿、提交、独立审批、发布、修订、追加式回滚和全链路审计;
+6. 语义 JSON、受控 RDF/XML 导出,以及既有本体 JSON/OWL 交换能力;
+7. 语义对象、数据标准和本体发布事件进入 outbox,并在知识配置就绪后同步 canonical
+   治理知识。
+
+本工作包没有复制数据标准、数据元素或本体服务,也没有扩展为分析开发平台。
+
+## 2. 发布、安全与一致性边界
+
+- editor 可创建、修订、提交和建立字段映射;viewer 只读;admin 才能审批、发布和回滚;
+- 创建者不得审批同一版本,版本不原地覆盖,回滚追加新发布版本并保存来源版本号;
+- 关联物理资产必须是活动主动元数据,数据元素和标准版本必须已发布;
+- 代码集内代码唯一,父代码必须存在且不能自引用,跨对象代码引用必须指向已发布代码值;
+- 指标请求若携带 SQL、query、script、executable 或 runtime 字段即失败关闭;
+- 密码、令牌、密钥、凭据和连接串语义字段禁止进入定义和导出;
+- 消费器只领取三类已注册治理发布事件,不会把其他 outbox 事件错误重试或死信;
+- 发布事件只有在 canonical 知识同步完成后才由消费者确认;知识配置不完整时保留 pending;
+- 本体 JSON/OWL 沿用既有交换接口;通用语义只提供 JSON 与受控 RDF,不伪造 OWL。
+
+## 3. 第二业务域工程样本
+
+真实数据库定向验收使用不含企业秘密的客户主数据样本,完成:
+
+- “客户等级”业务术语;
+- “客户等级”代码集及 `VIP → VIP_A` 父子代码;
+- “重要客户占比”指标定义、区域维度和两版口径;
+- 一项已发布客户主数据标准版本引用;
+- `customers.customer_level` 到“客户等级”数据元素的字段映射;
+- 三类语义对象从草稿、提交、独立审批到发布;
+- 指标 V1 发布、V2 修订发布、再追加 V3 回滚至 V1 口径。
+
+上述是本地工程样本,不是企业正式术语、代码、标准、指标或责任人验收。P2-WP12 仍需绑定
+企业真实物料/备件样本和责任矩阵。
+
+## 4. 定向验证范围
+
+遵循工作包级验证策略,本次没有执行全量回归,定向覆盖:
+
+- 核心生命周期、引用完整性、指标执行边界、字段映射、回滚、审计和安全导出;
+- viewer/editor/admin API 权限及页面工作流;
+- PostgreSQL 六张新增表、约束、迁移头和真实发布闭环;
+- 语义知识点构建、发布事件注册和配置缺失时的安全等待;
+- 既有 StandardVersion 编辑/管理员分离发布门禁及本体 JSON/OWL 交换;
+- OpenAPI 路由清单、发布副本一致性、前端 ESLint 与生产构建。
+
+## 5. 本地工程验证结果(2026-07-31)
+
+- Alembic 已从 `20260730_380` 升级到 `20260731_390`;
+- 真实 PostgreSQL 闭环测试通过,验证三类对象、字段映射、九条指标审计、三次指标发布事件、
+  一次带审计的数据标准发布事件,以及 V1/V2 `superseded`、V3 `published` 的追加式回滚结果;
+- WP03 核心、API、权限、迁移、前端、知识同步和 OpenAPI 定向测试通过;
+- 本次触达 Python 文件 Ruff 检查通过,前端新增/触达文件 ESLint 无错误;
+- 前端生产构建成功;仅保留项目既有 Browserslist、旧文件 console、CSS 顺序和包体积警告;
+- OpenAPI 由当前源码重新生成,共 250 个操作,包含 14 个语义治理操作;
+- `app/` 与 `deployment/app/` 的新增和触达后端文件逐字节一致。
+
+以上结论是当前分支的本地工程完成,不代表远端已推送、环境已部署、企业语义已确认或知识
+embedding 服务已完成现场验收。

+ 54 - 0
frontend/src/api/dataDevelopment.js

@@ -8,6 +8,8 @@ const DEVICE_ENTITY_BASE = '/development/v1/device-entities'
 const DEVICE_QUALITY_BASE = '/development/v1/device-quality'
 const DEVICE_OBSERVABILITY_BASE = '/development/v1/device-observability'
 const GOVERNANCE_METRIC_BASE = '/development/v1/governance-metrics'
+const SEMANTIC_BASE = '/development/v1/semantic-assets'
+const SEMANTIC_MAPPING_BASE = '/development/v1/semantic-field-mappings'
 
 export const createIngestionJob = params => http.post(BASE, params)
 export const getIngestionJobs = params => http.get(BASE, params)
@@ -152,6 +154,46 @@ export const getGovernanceMetricDetails = params => http.get(
   `${GOVERNANCE_METRIC_BASE}/details`,
   params
 )
+export const getSemanticAssets = params => http.get(SEMANTIC_BASE, params)
+export const createSemanticAsset = params => http.post(SEMANTIC_BASE, params)
+export const getSemanticAssetVersions = uid => http.get(
+  `${SEMANTIC_BASE}/${uid}/versions`
+)
+export const getSemanticAssetAudits = uid => http.get(
+  `${SEMANTIC_BASE}/${uid}/audits`
+)
+export const reviseSemanticAsset = (uid, params) => http.post(
+  `${SEMANTIC_BASE}/${uid}/revisions`,
+  params
+)
+export const submitSemanticAsset = (uid, expectedVersion) => http.post(
+  `${SEMANTIC_BASE}/${uid}/submit`,
+  { expected_version: expectedVersion }
+)
+export const reviewSemanticAsset = (uid, params) => http.post(
+  `${SEMANTIC_BASE}/${uid}/review`,
+  params
+)
+export const publishSemanticAsset = (uid, expectedVersion) => http.post(
+  `${SEMANTIC_BASE}/${uid}/publish`,
+  { expected_version: expectedVersion }
+)
+export const rollbackSemanticAsset = (uid, params) => http.post(
+  `${SEMANTIC_BASE}/${uid}/rollback`,
+  params
+)
+export const getSemanticFieldMappings = params => http.get(
+  SEMANTIC_MAPPING_BASE,
+  params
+)
+export const createSemanticFieldMapping = params => http.post(
+  SEMANTIC_MAPPING_BASE,
+  params
+)
+export const exportSemanticAssets = format => http.getDownload(
+  `${SEMANTIC_BASE}/export`,
+  { format }
+)
 export const getDeviceQualityProfile = () => http.get(
   `${DEVICE_QUALITY_BASE}/profile`
 )
@@ -297,6 +339,18 @@ export default {
   getDeviceEntityMerges,
   rollbackDeviceEntityMerge,
   getDeviceEntityRollbacks,
+  getSemanticAssets,
+  createSemanticAsset,
+  getSemanticAssetVersions,
+  getSemanticAssetAudits,
+  reviseSemanticAsset,
+  submitSemanticAsset,
+  reviewSemanticAsset,
+  publishSemanticAsset,
+  rollbackSemanticAsset,
+  getSemanticFieldMappings,
+  createSemanticFieldMapping,
+  exportSemanticAssets,
   getDeviceQualityProfile,
   bootstrapDeviceQuality,
   createDeviceQualityVersion,

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

@@ -434,6 +434,22 @@ export default {
           metastr: '{"keepAlive":false,"allowClick":true,"enName":"Metadata","editModules":false,"title":"元数据","fullScreen":false,"target":false}',
           open: null
         },
+        {
+          hidden: 1,
+          type: 1,
+          title: '通用术语与标准',
+          path: '/data-governance/data-standard/semantic-governance',
+          children: [],
+          label: '通用术语与标准',
+          component: 'dataGovernance/dataStandard/semanticGovernance',
+          meta: {
+            roles: ['viewer', 'editor', 'admin'],
+            title: '通用术语与标准',
+            readOnly: 'viewer'
+          },
+          name: 'semanticGovernance',
+          alwaysShow: 0
+        },
         {
           meun: '',
           code: '',

+ 4 - 0
frontend/src/views/dataGovernance/dataStandard/index.vue

@@ -25,6 +25,10 @@
         </v-chip>
       </template>
       <template #navBtn>
+        <v-btn class="elevation-2 mr-2" rounded outlined color="primary" @click="$router.push('/data-governance/data-standard/semantic-governance')">
+          <v-icon left>mdi-book-education-outline</v-icon>
+          通用术语与标准
+        </v-btn>
         <v-btn class="elevation-5 mr-2 buttons" rounded color="primary" @click="handleAdd">
           <v-icon left>mdi-plus</v-icon>
           新增标准

+ 348 - 0
frontend/src/views/dataGovernance/dataStandard/semanticGovernance.vue

@@ -0,0 +1,348 @@
+<template>
+  <div class="pa-6 semantic-governance">
+    <div class="d-flex flex-wrap align-start justify-space-between mb-5">
+      <div>
+        <h1 class="text-h4 mb-2">通用术语与标准</h1>
+        <p class="text--secondary mb-0">
+          统一治理业务术语、通用代码集和指标口径,并关联数据标准、数据元素与物理资产。
+        </p>
+      </div>
+      <div class="mt-2">
+        <v-btn outlined color="primary" class="mr-2" @click="download('json')">导出 JSON</v-btn>
+        <v-btn outlined color="primary" class="mr-2" @click="download('rdf')">导出 RDF</v-btn>
+        <v-btn v-if="canEdit" color="primary" @click="openCreate">
+          <v-icon left>mdi-plus</v-icon>新建语义对象
+        </v-btn>
+      </div>
+    </div>
+
+    <v-alert type="info" outlined>
+      本平台只治理指标定义,不执行 BI 计算。OWL 交换沿用本体中心,术语和口径提供受控 JSON、RDF 导出。
+    </v-alert>
+
+    <v-tabs v-model="tab" background-color="transparent" class="mb-4">
+      <v-tab v-for="item in kinds" :key="item.value">{{ item.label }}</v-tab>
+      <v-tab>物理字段映射</v-tab>
+    </v-tabs>
+
+    <v-card v-if="tab < kinds.length" outlined>
+      <v-card-title>
+        {{ kinds[tab].label }}
+        <v-spacer />
+        <v-btn icon :loading="loading" @click="loadAssets"><v-icon>mdi-refresh</v-icon></v-btn>
+      </v-card-title>
+      <v-data-table :headers="headers" :items="assets" :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.definition`]="{ item }">
+          {{ item.definition && item.definition.definition }}
+        </template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <v-btn
+            v-if="canEdit && item.status === 'draft'"
+            text small color="primary"
+            @click="submit(item)"
+          >提交审核</v-btn>
+          <v-btn
+            v-if="isAdmin && item.status === 'in_review'"
+            text small color="success"
+            @click="review(item)"
+          >审批通过</v-btn>
+          <v-btn
+            v-if="isAdmin && item.status === 'approved'"
+            text small color="primary"
+            @click="publish(item)"
+          >发布</v-btn>
+          <v-btn
+            v-if="isAdmin && item.status === 'published'"
+            text small color="warning"
+            @click="openRollback(item)"
+          >回滚</v-btn>
+        </template>
+        <template #no-data>
+          <div class="py-8 text--secondary">暂无{{ kinds[tab].label }}。</div>
+        </template>
+      </v-data-table>
+    </v-card>
+
+    <v-card v-else outlined>
+      <v-card-title>
+        物理字段映射
+        <v-spacer />
+        <v-btn v-if="canEdit" color="primary" small @click="mappingDialog = true">新增映射</v-btn>
+      </v-card-title>
+      <v-data-table :headers="mappingHeaders" :items="mappings" :loading="loading">
+        <template #no-data><div class="py-8 text--secondary">暂无字段到数据元素映射。</div></template>
+      </v-data-table>
+    </v-card>
+
+    <v-dialog v-model="createDialog" max-width="760">
+      <v-card>
+        <v-card-title>新建{{ kindLabel(form.asset_kind) }}</v-card-title>
+        <v-card-text>
+          <v-select v-model="form.asset_kind" :items="kinds" item-text="label" item-value="value" label="对象类型" />
+          <v-text-field v-model="form.code" label="编码" />
+          <v-text-field v-model="form.name" label="名称" />
+          <v-text-field v-model="form.owner_uid" label="责任人 UID" />
+          <v-text-field v-model="form.business_domain_uid" label="业务域 UID" />
+          <v-textarea
+            v-model="definitionText"
+            rows="10"
+            label="定义 JSON"
+            hint="业务术语可含 aliases;代码集含 values;指标口径含 formula、unit、dimensions"
+            persistent-hint
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="createDialog = false">取消</v-btn>
+          <v-btn color="primary" @click="createAsset">保存草稿</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="rollbackDialog" max-width="520">
+      <v-card>
+        <v-card-title>回滚已发布版本</v-card-title>
+        <v-card-text>
+          <v-text-field v-model.number="rollbackForm.target_version" type="number" min="1" label="目标版本" />
+          <v-textarea v-model="rollbackForm.reason" label="回滚原因" />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="rollbackDialog = false">取消</v-btn>
+          <v-btn color="warning" @click="rollback">确认回滚</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="mappingDialog" max-width="620">
+      <v-card>
+        <v-card-title>新增物理字段映射</v-card-title>
+        <v-card-text>
+          <v-text-field v-model="mappingForm.asset_uid" label="主动元数据资产 UID" />
+          <v-text-field v-model="mappingForm.field_name" label="物理字段名" />
+          <v-text-field v-model="mappingForm.data_element_uid" label="已发布数据元素 UID" />
+          <v-text-field v-model="mappingForm.owner_uid" label="责任人 UID" />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="mappingDialog = false">取消</v-btn>
+          <v-btn color="primary" @click="createMapping">保存映射</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  createSemanticAsset,
+  createSemanticFieldMapping,
+  exportSemanticAssets,
+  getSemanticAssets,
+  getSemanticFieldMappings,
+  publishSemanticAsset,
+  reviewSemanticAsset,
+  rollbackSemanticAsset,
+  submitSemanticAsset
+} from '@/api/dataDevelopment'
+
+const EMPTY_DEFINITION = JSON.stringify({
+  definition: '',
+  related_asset_uids: [],
+  related_data_element_uids: [],
+  standard_version_uids: []
+}, null, 2)
+
+export default {
+  name: 'SemanticGovernance',
+  data: () => ({
+    tab: 0,
+    loading: false,
+    assets: [],
+    mappings: [],
+    createDialog: false,
+    rollbackDialog: false,
+    mappingDialog: false,
+    selected: null,
+    kinds: [
+      { label: '业务术语', value: 'business_term' },
+      { label: '通用代码集', value: 'code_set' },
+      { label: '指标口径', value: 'metric' }
+    ],
+    form: { asset_kind: 'business_term', code: '', name: '', owner_uid: '', business_domain_uid: '' },
+    definitionText: EMPTY_DEFINITION,
+    rollbackForm: { target_version: 1, reason: '' },
+    mappingForm: { asset_uid: '', field_name: '', data_element_uid: '', owner_uid: '' },
+    headers: [
+      { text: '编码', value: 'code' },
+      { text: '名称', value: 'name' },
+      { text: '定义', value: 'definition' },
+      { text: '责任人', value: 'owner_uid' },
+      { text: '版本', value: 'current_version' },
+      { text: '状态', value: 'status' },
+      { text: '操作', value: 'actions', sortable: false }
+    ],
+    mappingHeaders: [
+      { text: '资产 UID', value: 'asset_uid' },
+      { text: '物理字段', value: 'field_name' },
+      { text: '数据元素 UID', value: 'data_element_uid' },
+      { text: '元素版本', value: 'data_element_version' },
+      { text: '责任人', value: 'owner_uid' },
+      { text: '状态', value: 'status' }
+    ]
+  }),
+  computed: {
+    roles () {
+      return this.$store.getters.roles || []
+    },
+    isAdmin () {
+      return this.roles.includes('admin')
+    },
+    canEdit () {
+      return this.roles.includes('editor') || this.isAdmin
+    }
+  },
+  watch: {
+    tab () {
+      this.load()
+    }
+  },
+  created () {
+    this.load()
+  },
+  methods: {
+    kindLabel (value) {
+      return this.kinds.find(item => item.value === value)?.label || '语义对象'
+    },
+    statusView (status) {
+      return {
+        draft: { label: '草稿', color: 'grey' },
+        in_review: { label: '审核中', color: 'warning' },
+        approved: { label: '已批准', color: 'info' },
+        published: { label: '已发布', color: 'success' }
+      }[status] || { label: status, color: 'grey' }
+    },
+    async load () {
+      if (this.tab < this.kinds.length) await this.loadAssets()
+      else await this.loadMappings()
+    },
+    async loadAssets () {
+      this.loading = true
+      try {
+        const response = await getSemanticAssets({ asset_kind: this.kinds[this.tab].value })
+        this.assets = response.data || []
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '语义对象加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    async loadMappings () {
+      this.loading = true
+      try {
+        const response = await getSemanticFieldMappings()
+        this.mappings = response.data || []
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '字段映射加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    openCreate () {
+      this.form = { asset_kind: this.kinds[this.tab]?.value || 'business_term', code: '', name: '', owner_uid: '', business_domain_uid: '' }
+      this.definitionText = EMPTY_DEFINITION
+      this.createDialog = true
+    },
+    async createAsset () {
+      try {
+        const definition = JSON.parse(this.definitionText)
+        await createSemanticAsset({ ...this.form, ...definition })
+        this.$snackbar.success('语义草稿已创建')
+        this.createDialog = false
+        await this.loadAssets()
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '语义草稿创建失败')
+      }
+    },
+    async submit (item) {
+      try {
+        await submitSemanticAsset(item.uid, item.current_version)
+        this.$snackbar.success('已提交审核')
+        await this.loadAssets()
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '提交失败')
+      }
+    },
+    async review (item) {
+      try {
+        await reviewSemanticAsset(item.uid, { expected_version: item.current_version, decision: 'approve', reason: '工作台审批通过' })
+        this.$snackbar.success('审批通过')
+        await this.loadAssets()
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '审批失败')
+      }
+    },
+    async publish (item) {
+      try {
+        await publishSemanticAsset(item.uid, item.current_version)
+        this.$snackbar.success('已发布并进入知识同步队列')
+        await this.loadAssets()
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '发布失败')
+      }
+    },
+    openRollback (item) {
+      this.selected = item
+      this.rollbackForm = { target_version: Math.max(1, item.current_version - 1), reason: '' }
+      this.rollbackDialog = true
+    },
+    async rollback () {
+      try {
+        await rollbackSemanticAsset(this.selected.uid, {
+          expected_version: this.selected.current_version,
+          target_version: this.rollbackForm.target_version,
+          reason: this.rollbackForm.reason
+        })
+        this.$snackbar.success('已追加回滚版本')
+        this.rollbackDialog = false
+        await this.loadAssets()
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '回滚失败')
+      }
+    },
+    async createMapping () {
+      try {
+        await createSemanticFieldMapping(this.mappingForm)
+        this.$snackbar.success('物理字段映射已保存')
+        this.mappingDialog = false
+        await this.loadMappings()
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '字段映射保存失败')
+      }
+    },
+    async download (format) {
+      try {
+        const response = await exportSemanticAssets(format)
+        const url = URL.createObjectURL(response.data)
+        const anchor = document.createElement('a')
+        anchor.href = url
+        anchor.download = `semantic-governance.${format}`
+        anchor.click()
+        URL.revokeObjectURL(url)
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '交换文件导出失败')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.semantic-governance { background: #f7f9fc; min-height: 100%; }
+.semantic-governance h1 { color: #172554; }
+</style>

+ 162 - 0
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"
+    )

+ 456 - 0
tests/data_research/test_semantic_governance.py

@@ -0,0 +1,456 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from datetime import UTC, datetime
+
+import pytest
+
+ACTOR_UID = "01900000-0000-7000-8000-000000001001"
+REVIEWER_UID = "01900000-0000-7000-8000-000000001002"
+OWNER_UID = "01900000-0000-7000-8000-000000001003"
+DOMAIN_UID = "01900000-0000-7000-8000-000000001004"
+SOURCE_ASSET_UID = "01900000-0000-7000-8000-000000001005"
+DATA_ELEMENT_UID = "01900000-0000-7000-8000-000000001006"
+STANDARD_VERSION_UID = "01900000-0000-7000-8000-000000001007"
+
+
+class MemorySemanticRepository:
+    def __init__(self):
+        self.assets = {}
+        self.versions = {}
+        self.reviews = []
+        self.audits = []
+        self.mappings = {}
+        self.active_metadata_assets = {
+            SOURCE_ASSET_UID: {
+                "uid": SOURCE_ASSET_UID,
+                "snapshot": {
+                    "fields": [
+                        {"name": "material_code", "data_type": "varchar"},
+                        {"name": "status", "data_type": "varchar"},
+                    ]
+                },
+            }
+        }
+        self.data_elements = {
+            DATA_ELEMENT_UID: {
+                "uid": DATA_ELEMENT_UID,
+                "status": "published",
+                "current_version": 3,
+            }
+        }
+        self.standard_versions = {
+            STANDARD_VERSION_UID: {
+                "id": STANDARD_VERSION_UID,
+                "status": "published",
+            }
+        }
+
+    def get_by_code(self, asset_kind, code):
+        return next(
+            (
+                deepcopy(item)
+                for item in self.assets.values()
+                if item["asset_kind"] == asset_kind and item["code"] == code
+            ),
+            None,
+        )
+
+    def save_new(self, asset, version, audit, links):
+        self.assets[asset["uid"]] = deepcopy(asset)
+        self.versions[(asset["uid"], version["version"])] = deepcopy(version)
+        self.audits.append(deepcopy(audit))
+        self._links = deepcopy(links)
+        return self.get(asset["uid"])
+
+    def get(self, uid):
+        item = self.assets.get(uid)
+        if item is None:
+            return None
+        version = self.versions[(uid, item["current_version"])]
+        return {**deepcopy(item), "definition": deepcopy(version["definition"])}
+
+    def list(self, *, asset_kind=None, status=None):
+        items = [self.get(uid) for uid in self.assets]
+        if asset_kind:
+            items = [item for item in items if item["asset_kind"] == asset_kind]
+        if status:
+            items = [item for item in items if item["status"] == status]
+        return items
+
+    def list_versions(self, uid):
+        return [
+            deepcopy(version)
+            for (asset_uid, _version), version in sorted(self.versions.items())
+            if asset_uid == uid
+        ]
+
+    def list_reviews(self, uid):
+        return [deepcopy(item) for item in self.reviews if item["asset_uid"] == uid]
+
+    def list_audits(self, uid):
+        return [deepcopy(item) for item in self.audits if item["target_uid"] == uid]
+
+    def transition(self, asset, version, audit, review=None):
+        self.assets[asset["uid"]] = deepcopy(asset)
+        self.versions[(asset["uid"], version["version"])] = deepcopy(version)
+        self.audits.append(deepcopy(audit))
+        if review:
+            self.reviews.append(deepcopy(review))
+        return self.get(asset["uid"])
+
+    def append_version(self, asset, version, audit, links):
+        self.assets[asset["uid"]] = deepcopy(asset)
+        self.versions[(asset["uid"], version["version"])] = deepcopy(version)
+        self.audits.append(deepcopy(audit))
+        self._links = deepcopy(links)
+        return self.get(asset["uid"])
+
+    def active_assets_exist(self, uids):
+        return set(uids).issubset(self.active_metadata_assets)
+
+    def published_data_elements_exist(self, uids):
+        return all(
+            self.data_elements.get(uid, {}).get("status") == "published"
+            for uid in uids
+        )
+
+    def published_standard_versions_exist(self, uids):
+        return all(
+            self.standard_versions.get(uid, {}).get("status") == "published"
+            for uid in uids
+        )
+
+    def published_code_exists(self, code_set_uid, code):
+        asset = self.assets.get(code_set_uid)
+        if not asset or asset["status"] != "published":
+            return False
+        definition = self.versions[
+            (code_set_uid, asset["current_version"])
+        ]["definition"]
+        return code in {item["code"] for item in definition["values"]}
+
+    def get_active_metadata_asset(self, uid):
+        return deepcopy(self.active_metadata_assets.get(uid))
+
+    def get_data_element(self, uid):
+        return deepcopy(self.data_elements.get(uid))
+
+    def save_field_mapping(self, mapping, audit):
+        key = (
+            mapping["asset_uid"],
+            mapping["field_name"],
+            mapping["data_element_uid"],
+        )
+        if key in self.mappings:
+            return deepcopy(self.mappings[key])
+        self.mappings[key] = deepcopy(mapping)
+        self.audits.append(deepcopy(audit))
+        return deepcopy(mapping)
+
+    def list_field_mappings(self, *, asset_uid=None, data_element_uid=None):
+        result = list(self.mappings.values())
+        if asset_uid:
+            result = [item for item in result if item["asset_uid"] == asset_uid]
+        if data_element_uid:
+            result = [
+                item
+                for item in result
+                if item["data_element_uid"] == data_element_uid
+            ]
+        return deepcopy(result)
+
+
+def _service(repository=None, events=None):
+    from app.core.data_research.semantic_governance import (
+        SemanticGovernanceService,
+    )
+
+    ids = (f"01900000-0000-7000-8000-{index:012d}" for index in range(2001, 9999))
+    event_list = events if events is not None else []
+    return SemanticGovernanceService(
+        repository or MemorySemanticRepository(),
+        uid_factory=ids.__next__,
+        now_factory=lambda: datetime(2026, 7, 31, 9, 0, tzinfo=UTC),
+        outbox_enqueue=lambda **event: event_list.append(deepcopy(event)),
+    )
+
+
+def _term_payload(**overrides):
+    payload = {
+        "code": "TERM_MATERIAL",
+        "name": "物料",
+        "owner_uid": OWNER_UID,
+        "business_domain_uid": DOMAIN_UID,
+        "definition": "企业采购、库存和维修过程中统一识别的物资。",
+        "aliases": ["备件", "物资"],
+        "related_asset_uids": [SOURCE_ASSET_UID],
+        "related_data_element_uids": [DATA_ELEMENT_UID],
+        "standard_version_uids": [STANDARD_VERSION_UID],
+    }
+    payload.update(overrides)
+    return payload
+
+
+def _publish(service, asset):
+    submitted = service.submit(
+        asset["uid"],
+        expected_version=asset["current_version"],
+        actor_uid=ACTOR_UID,
+    )
+    approved = service.review(
+        asset["uid"],
+        expected_version=submitted["current_version"],
+        decision="approve",
+        reason="定义和关联证据完整",
+        actor_uid=REVIEWER_UID,
+    )
+    return service.publish(
+        asset["uid"],
+        expected_version=approved["current_version"],
+        actor_uid=REVIEWER_UID,
+    )
+
+
+def test_business_term_uses_distinct_review_and_publishes_knowledge_event():
+    repository = MemorySemanticRepository()
+    events = []
+    service = _service(repository, events)
+    draft = service.create_draft(
+        "business_term",
+        _term_payload(),
+        actor_uid=ACTOR_UID,
+    )
+
+    with pytest.raises(ValueError, match="self-review"):
+        service.review(
+            draft["uid"],
+            expected_version=1,
+            decision="approve",
+            reason="不能自审",
+            actor_uid=ACTOR_UID,
+        )
+
+    published = _publish(service, draft)
+
+    assert published["status"] == "published"
+    assert published["definition"]["aliases"] == ["备件", "物资"]
+    assert [item["action"] for item in repository.list_audits(draft["uid"])] == [
+        "created",
+        "submitted",
+        "approved",
+        "published",
+    ]
+    assert events == [
+        {
+            "aggregate_type": "semantic_asset",
+            "aggregate_id": draft["uid"],
+            "event_type": "semantic_asset.version_published",
+            "payload": {
+                "uid": draft["uid"],
+                "asset_kind": "business_term",
+                "version": 1,
+            },
+        }
+    ]
+
+
+def test_code_set_validates_parent_and_cross_asset_references():
+    repository = MemorySemanticRepository()
+    service = _service(repository)
+
+    with pytest.raises(ValueError, match="parent_code"):
+        service.create_draft(
+            "code_set",
+            {
+                "code": "MATERIAL_STATUS",
+                "name": "物料状态",
+                "owner_uid": OWNER_UID,
+                "business_domain_uid": DOMAIN_UID,
+                "definition": "物料生命周期状态。",
+                "values": [
+                    {"code": "ACTIVE", "name": "有效", "parent_code": "MISSING"}
+                ],
+            },
+            actor_uid=ACTOR_UID,
+        )
+
+    code_set = service.create_draft(
+        "code_set",
+        {
+            "code": "MATERIAL_STATUS",
+            "name": "物料状态",
+            "owner_uid": OWNER_UID,
+            "business_domain_uid": DOMAIN_UID,
+            "definition": "物料生命周期状态。",
+            "values": [
+                {"code": "ALL", "name": "全部"},
+                {"code": "ACTIVE", "name": "有效", "parent_code": "ALL"},
+                {"code": "FROZEN", "name": "冻结", "parent_code": "ALL"},
+            ],
+        },
+        actor_uid=ACTOR_UID,
+    )
+    published_codes = _publish(service, code_set)
+    term = service.create_draft(
+        "business_term",
+        _term_payload(
+            code="TERM_ACTIVE_MATERIAL",
+            code_references=[
+                {"code_set_uid": published_codes["uid"], "code": "ACTIVE"}
+            ],
+        ),
+        actor_uid=ACTOR_UID,
+    )
+
+    assert term["definition"]["code_references"][0]["code"] == "ACTIVE"
+    with pytest.raises(ValueError, match="published code"):
+        service.create_draft(
+            "business_term",
+            _term_payload(
+                code="TERM_BAD_STATUS",
+                code_references=[
+                    {"code_set_uid": published_codes["uid"], "code": "UNKNOWN"}
+                ],
+            ),
+            actor_uid=ACTOR_UID,
+        )
+
+
+def test_metric_definition_keeps_dimensions_and_formula_without_execution():
+    service = _service()
+    metric = service.create_draft(
+        "metric",
+        {
+            "code": "MATERIAL_COMPLETENESS",
+            "name": "物料主数据完整率",
+            "owner_uid": OWNER_UID,
+            "business_domain_uid": DOMAIN_UID,
+            "definition": "关键字段完整的有效物料占全部有效物料的比例。",
+            "formula": "关键字段完整的有效物料数 / 全部有效物料数 * 100%",
+            "unit": "%",
+            "dimensions": [
+                {"code": "category", "name": "物料分类"},
+                {"code": "organization", "name": "组织"},
+            ],
+            "related_asset_uids": [SOURCE_ASSET_UID],
+            "related_data_element_uids": [DATA_ELEMENT_UID],
+        },
+        actor_uid=ACTOR_UID,
+    )
+
+    assert metric["definition"]["dimensions"][0]["code"] == "category"
+    assert "execution_sql" not in metric["definition"]
+    with pytest.raises(ValueError, match="analysis execution"):
+        service.create_draft(
+            "metric",
+            {
+                **_term_payload(code="BAD_METRIC"),
+                "formula": "select count(*) from material",
+                "execution_sql": "select count(*) from material",
+            },
+            actor_uid=ACTOR_UID,
+        )
+
+
+def test_rollback_appends_published_version_and_preserves_history():
+    repository = MemorySemanticRepository()
+    events = []
+    service = _service(repository, events)
+    first = _publish(
+        service,
+        service.create_draft("business_term", _term_payload(), actor_uid=ACTOR_UID),
+    )
+    revised = service.revise(
+        first["uid"],
+        _term_payload(definition="第二版定义。"),
+        expected_version=1,
+        reason="更新口径",
+        actor_uid=ACTOR_UID,
+    )
+    second = _publish(service, revised)
+
+    rolled_back = service.rollback(
+        second["uid"],
+        target_version=1,
+        expected_version=2,
+        reason="第二版口径不适用",
+        actor_uid=REVIEWER_UID,
+    )
+
+    assert rolled_back["current_version"] == 3
+    assert rolled_back["status"] == "published"
+    assert rolled_back["definition"]["definition"] == _term_payload()["definition"]
+    versions = repository.list_versions(first["uid"])
+    assert [item["version"] for item in versions] == [1, 2, 3]
+    assert versions[-1]["rollback_from_version"] == 1
+    assert repository.list_audits(first["uid"])[-1]["action"] == "rolled_back"
+    assert events[-1]["payload"]["version"] == 3
+
+
+def test_physical_field_mapping_requires_published_element_and_real_field():
+    repository = MemorySemanticRepository()
+    service = _service(repository)
+    mapping = service.map_physical_field(
+        {
+            "asset_uid": SOURCE_ASSET_UID,
+            "field_name": "material_code",
+            "data_element_uid": DATA_ELEMENT_UID,
+            "owner_uid": OWNER_UID,
+            "evidence": {"catalog_snapshot_uid": "snapshot-1"},
+        },
+        actor_uid=ACTOR_UID,
+    )
+
+    assert mapping["status"] == "published"
+    assert mapping["data_element_version"] == 3
+    assert service.list_field_mappings(asset_uid=SOURCE_ASSET_UID) == [mapping]
+
+    with pytest.raises(ValueError, match="physical field"):
+        service.map_physical_field(
+            {
+                "asset_uid": SOURCE_ASSET_UID,
+                "field_name": "missing_field",
+                "data_element_uid": DATA_ELEMENT_UID,
+                "owner_uid": OWNER_UID,
+            },
+            actor_uid=ACTOR_UID,
+        )
+    repository.data_elements[DATA_ELEMENT_UID]["status"] = "draft"
+    with pytest.raises(ValueError, match="published data element"):
+        service.map_physical_field(
+            {
+                "asset_uid": SOURCE_ASSET_UID,
+                "field_name": "status",
+                "data_element_uid": DATA_ELEMENT_UID,
+                "owner_uid": OWNER_UID,
+            },
+            actor_uid=ACTOR_UID,
+        )
+
+
+def test_semantic_exchange_exports_only_published_assets_as_json_and_rdf():
+    from app.core.data_research.semantic_governance import SemanticExchange
+
+    repository = MemorySemanticRepository()
+    service = _service(repository)
+    published = _publish(
+        service,
+        service.create_draft("business_term", _term_payload(), actor_uid=ACTOR_UID),
+    )
+    service.create_draft(
+        "business_term",
+        _term_payload(code="TERM_DRAFT"),
+        actor_uid=ACTOR_UID,
+    )
+    exchange = SemanticExchange()
+
+    json_document = exchange.export_json(service.list(status="published"))
+    rdf_document = exchange.export_rdf(service.list(status="published"))
+
+    assert published["code"].encode() in json_document
+    assert b"TERM_DRAFT" not in json_document
+    assert b"rdf:RDF" in rdf_document
+    assert b"BusinessTerm" in rdf_document
+    assert b"password" not in json_document.lower()
+    assert b"password" not in rdf_document.lower()

+ 176 - 0
tests/data_research/test_semantic_governance_api.py

@@ -0,0 +1,176 @@
+from __future__ import annotations
+
+import uuid
+
+import pytest
+
+
+class FakeSemanticService:
+    def __init__(self):
+        self.calls = []
+        self.asset = {
+            "uid": str(uuid.uuid4()),
+            "asset_kind": "business_term",
+            "code": "CUSTOMER_LEVEL",
+            "name": "客户等级",
+            "owner_uid": str(uuid.uuid4()),
+            "business_domain_uid": str(uuid.uuid4()),
+            "status": "draft",
+            "current_version": 1,
+            "definition": {"definition": "客户运营分层等级", "aliases": []},
+        }
+
+    def list(self, **filters):
+        self.calls.append(("list", filters))
+        return [self.asset]
+
+    def create_draft(self, kind, payload, *, actor_uid):
+        self.calls.append(("create", kind, payload, actor_uid))
+        return self.asset
+
+    def submit(self, uid, *, expected_version, actor_uid):
+        self.calls.append(("submit", uid, expected_version, actor_uid))
+        return {**self.asset, "status": "in_review"}
+
+    def review(self, uid, **payload):
+        self.calls.append(("review", uid, payload))
+        return {**self.asset, "status": "approved"}
+
+    def publish(self, uid, *, expected_version, actor_uid):
+        self.calls.append(("publish", uid, expected_version, actor_uid))
+        return {**self.asset, "status": "published"}
+
+    def rollback(self, uid, **payload):
+        self.calls.append(("rollback", uid, payload))
+        return {
+            **self.asset,
+            "status": "published",
+            "current_version": 2,
+        }
+
+    def list_field_mappings(self, **filters):
+        self.calls.append(("mappings", filters))
+        return []
+
+    def map_physical_field(self, payload, *, actor_uid):
+        self.calls.append(("map", payload, actor_uid))
+        return {"uid": str(uuid.uuid4()), **payload, "status": "published"}
+
+
+@pytest.fixture()
+def client(monkeypatch):
+    from flask import request
+
+    from app import create_app
+    from app.api.data_development import semantic_routes
+    from app.core.system import permissions
+
+    service = FakeSemanticService()
+
+    def identity():
+        role = request.headers.get("Authorization", "").removeprefix("Bearer ")
+        if role not in {"viewer", "editor", "admin"}:
+            return None
+        return {"id": f"00000000-0000-7000-8000-0000000000{role[0]}", "roles": [role]}
+
+    monkeypatch.setattr(permissions, "authenticate_request", identity)
+    monkeypatch.setattr(
+        semantic_routes,
+        "get_semantic_governance_service",
+        lambda: service,
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    return app.test_client(), service
+
+
+def test_viewer_lists_but_cannot_create(client):
+    http, service = client
+    listed = http.get(
+        "/api/development/v1/semantic-assets?asset_kind=business_term",
+        headers={"Authorization": "Bearer viewer"},
+    )
+    denied = http.post(
+        "/api/development/v1/semantic-assets",
+        headers={"Authorization": "Bearer viewer"},
+        json={"asset_kind": "business_term"},
+    )
+
+    assert listed.status_code == 200
+    assert listed.get_json()["data"][0]["code"] == "CUSTOMER_LEVEL"
+    assert service.calls[0] == (
+        "list",
+        {"asset_kind": "business_term", "status": None},
+    )
+    assert denied.status_code == 403
+
+
+def test_editor_creates_submits_and_maps_but_cannot_approve(client):
+    http, service = client
+    headers = {"Authorization": "Bearer editor"}
+    created = http.post(
+        "/api/development/v1/semantic-assets",
+        headers=headers,
+        json={"asset_kind": "business_term", "code": "CUSTOMER_LEVEL"},
+    )
+    submitted = http.post(
+        f"/api/development/v1/semantic-assets/{service.asset['uid']}/submit",
+        headers=headers,
+        json={"expected_version": 1},
+    )
+    mapped = http.post(
+        "/api/development/v1/semantic-field-mappings",
+        headers=headers,
+        json={
+            "asset_uid": str(uuid.uuid4()),
+            "field_name": "customer_level",
+            "data_element_uid": str(uuid.uuid4()),
+            "owner_uid": str(uuid.uuid4()),
+        },
+    )
+    denied = http.post(
+        f"/api/development/v1/semantic-assets/{service.asset['uid']}/review",
+        headers=headers,
+        json={
+            "expected_version": 1,
+            "decision": "approve",
+            "reason": "approved",
+        },
+    )
+
+    assert created.status_code == 201
+    assert submitted.get_json()["data"]["status"] == "in_review"
+    assert mapped.status_code == 201
+    assert denied.status_code == 403
+
+
+def test_admin_approves_publishes_and_rolls_back(client):
+    http, service = client
+    headers = {"Authorization": "Bearer admin"}
+    reviewed = http.post(
+        f"/api/development/v1/semantic-assets/{service.asset['uid']}/review",
+        headers=headers,
+        json={
+            "expected_version": 1,
+            "decision": "approve",
+            "reason": "企业术语审核通过",
+        },
+    )
+    published = http.post(
+        f"/api/development/v1/semantic-assets/{service.asset['uid']}/publish",
+        headers=headers,
+        json={"expected_version": 1},
+    )
+    rolled_back = http.post(
+        f"/api/development/v1/semantic-assets/{service.asset['uid']}/rollback",
+        headers=headers,
+        json={
+            "expected_version": 1,
+            "target_version": 1,
+            "reason": "恢复验收版本",
+        },
+    )
+
+    assert reviewed.get_json()["data"]["status"] == "approved"
+    assert published.get_json()["data"]["status"] == "published"
+    assert rolled_back.get_json()["data"]["current_version"] == 2

+ 58 - 0
tests/data_research/test_semantic_governance_frontend_contract.py

@@ -0,0 +1,58 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_semantic_governance_workbench_exposes_cross_domain_release_chain():
+    page = (
+        ROOT
+        / "frontend/src/views/dataGovernance/dataStandard/"
+        "semanticGovernance.vue"
+    ).read_text(encoding="utf-8")
+    api = (ROOT / "frontend/src/api/dataDevelopment.js").read_text(
+        encoding="utf-8"
+    )
+    routes = (ROOT / "frontend/src/router/routes.js").read_text(
+        encoding="utf-8"
+    )
+    standard_center = (
+        ROOT / "frontend/src/views/dataGovernance/dataStandard/index.vue"
+    ).read_text(encoding="utf-8")
+
+    for text in (
+        "通用术语与标准",
+        "业务术语",
+        "通用代码集",
+        "指标口径",
+        "物理字段映射",
+        "草稿",
+        "审核中",
+        "已批准",
+        "已发布",
+        "提交审核",
+        "审批通过",
+        "发布",
+        "回滚",
+        "JSON",
+        "RDF",
+        "本平台只治理指标定义,不执行 BI 计算",
+    ):
+        assert text in page
+    for function_name in (
+        "getSemanticAssets",
+        "createSemanticAsset",
+        "submitSemanticAsset",
+        "reviewSemanticAsset",
+        "publishSemanticAsset",
+        "rollbackSemanticAsset",
+        "getSemanticFieldMappings",
+        "createSemanticFieldMapping",
+        "exportSemanticAssets",
+    ):
+        assert function_name in api
+    assert "/data-governance/data-standard/semantic-governance" in routes
+    assert "semanticGovernance" in routes
+    assert "通用术语与标准" in standard_center
+    assert "this.$snackbar.error" in page
+    assert "this.$message" not in page

+ 158 - 0
tests/data_research/test_semantic_knowledge_sync.py

@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+
+class RecordingKnowledgeSync:
+    def __init__(self):
+        self.snapshots = []
+
+    def sync(self, snapshot):
+        self.snapshots.append(snapshot)
+        return {
+            "status": "canonical_active",
+            "source_uid": snapshot.source_uid,
+            "source_revision": snapshot.source_revision,
+        }
+
+
+def test_published_semantic_asset_projects_to_canonical_knowledge_snapshot():
+    from app.core.data_research.semantic_knowledge import (
+        SemanticKnowledgeSyncHandler,
+    )
+
+    uid = "01900000-0000-7000-8000-000000009001"
+    semantic = type(
+        "SemanticService",
+        (),
+        {
+            "get_published_version": lambda self, asset_uid, version: {
+                "uid": asset_uid,
+                "asset_kind": "business_term",
+                "code": "TERM_MATERIAL",
+                "name": "物料",
+                "owner_uid": "01900000-0000-7000-8000-000000009002",
+                "business_domain_uid": "01900000-0000-7000-8000-000000009003",
+                "status": "published",
+                "current_version": version,
+                "definition": {
+                    "definition": "统一采购、库存和维修语义。",
+                    "aliases": ["备件", "物资"],
+                },
+            }
+        },
+    )()
+    knowledge = RecordingKnowledgeSync()
+    handler = SemanticKnowledgeSyncHandler(semantic, knowledge)
+
+    result = handler.handle({"uid": uid, "asset_kind": "business_term", "version": 3})
+
+    assert result["status"] == "canonical_active"
+    snapshot = knowledge.snapshots[0]
+    assert snapshot.source_type == "BusinessTerm"
+    assert snapshot.source_revision == 3
+    assert {
+        point.content
+        for point in snapshot.points
+        if "/aliases/" in point.point_key
+    } == {"备件", "物资"}
+
+
+def test_semantic_knowledge_handler_rejects_kind_mismatch():
+    import pytest
+
+    from app.core.data_research.semantic_knowledge import (
+        SemanticKnowledgeSyncHandler,
+    )
+
+    semantic = type(
+        "SemanticService",
+        (),
+        {
+            "get_published_version": lambda self, _uid, version: {
+                "uid": "01900000-0000-7000-8000-000000009011",
+                "asset_kind": "metric",
+                "status": "published",
+                "current_version": version,
+                "definition": {
+                    "definition": "完整率。",
+                    "formula": "完整数 / 总数",
+                    "unit": "%",
+                    "dimensions": [{"code": "category", "name": "分类"}],
+                },
+            }
+        },
+    )()
+
+    with pytest.raises(ValueError, match="kind"):
+        SemanticKnowledgeSyncHandler(
+            semantic,
+            RecordingKnowledgeSync(),
+        ).handle(
+            {
+                "uid": "01900000-0000-7000-8000-000000009011",
+                "asset_kind": "business_term",
+                "version": 1,
+            }
+        )
+
+
+def test_standard_and_ontology_publications_use_canonical_versioned_knowledge():
+    from app.core.data_research.semantic_knowledge import (
+        PublishedGovernanceKnowledgeHandler,
+    )
+
+    knowledge = RecordingKnowledgeSync()
+    handler = PublishedGovernanceKnowledgeHandler(
+        knowledge_sync=knowledge,
+        standard_loader=lambda uid: {
+            "uid": uid,
+            "standard_uid": "01900000-0000-7000-8000-000000000810",
+            "version": 3,
+            "status": "published",
+            "name": "客户主数据标准",
+            "definition": "统一客户等级",
+            "owner": "data-team",
+            "business_domain_uid": (
+                "01900000-0000-7000-8000-000000000811"
+            ),
+            "rules": [
+                {
+                    "uid": "customer-level",
+                    "name": "客户等级代码约束",
+                    "definition": "必须引用已发布代码值",
+                }
+            ],
+        },
+        ontology_loader=lambda uid: {
+            "uid": "01900000-0000-7000-8000-000000000820",
+            "version_uid": uid,
+            "version": 2,
+            "status": "published",
+            "name": "客户本体",
+            "definition": "客户跨域语义关系",
+            "owner": "ontology-team",
+            "business_domain_uid": (
+                "01900000-0000-7000-8000-000000000811"
+            ),
+            "classes": [
+                {
+                    "uid": "01900000-0000-7000-8000-000000000821",
+                    "name": "客户",
+                    "definition": "企业服务对象",
+                }
+            ],
+        },
+    )
+
+    standard = handler.handle_standard(
+        {"version_uid": "01900000-0000-7000-8000-000000000812"}
+    )
+    ontology = handler.handle_ontology(
+        {"version_uid": "01900000-0000-7000-8000-000000000822"}
+    )
+
+    assert standard["status"] == "canonical_active"
+    assert ontology["status"] == "canonical_active"
+    assert knowledge.snapshots[0].source_type == "DataStandard"
+    assert knowledge.snapshots[0].source_revision == 3
+    assert knowledge.snapshots[1].source_type == "Ontology"
+    assert knowledge.snapshots[1].source_revision == 2

+ 553 - 0
tests/integration/test_semantic_governance_postgres.py

@@ -0,0 +1,553 @@
+from __future__ import annotations
+
+import json
+import os
+
+import pytest
+from sqlalchemy import text
+
+pytestmark = pytest.mark.integration
+
+
+def test_semantic_governance_publishes_maps_and_rolls_back(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.common.identifiers import new_governance_uid
+    from app.core.data_research.semantic_governance import (
+        SemanticGovernanceService,
+    )
+    from app.core.data_research.semantic_repository import (
+        SqlAlchemySemanticGovernanceRepository,
+    )
+    from app.core.data_rules.repository import DataRuleRepository
+    from app.core.events.outbox import claim_outbox, enqueue_outbox
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    actor_uid = new_governance_uid()
+    owner_uid = new_governance_uid()
+    reviewer_uid = new_governance_uid()
+    source_uid = new_governance_uid()
+    plan_uid = new_governance_uid()
+    run_uid = new_governance_uid()
+    physical_asset_uid = new_governance_uid()
+    element_uid = new_governance_uid()
+    element_version_uid = new_governance_uid()
+    standard_uid = new_governance_uid()
+    standard_id = new_governance_uid()
+    standard_version_uid = new_governance_uid()
+    semantic_uids: list[str] = []
+
+    try:
+        with app.app_context():
+            for user_uid, label in (
+                (actor_uid, "editor"),
+                (owner_uid, "owner"),
+                (reviewer_uid, "reviewer"),
+            ):
+                db.session.execute(
+                    text(
+                        """
+                        INSERT INTO public.users (
+                            id, username, display_name, password_hash, status
+                        ) VALUES (
+                            CAST(:uid AS uuid), :username, :username,
+                            'p2-wp03-integration-hash', 'active'
+                        )
+                        """
+                    ),
+                    {
+                        "uid": user_uid,
+                        "username": f"wp03-{label}-{user_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"WP03 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,
+                        schedule_expression, discovery_mode, scope, owner_uid,
+                        enabled, cursor_state, created_by
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:source_uid AS uuid), :name,
+                        'database', 'manual', NULL, 'snapshot', '{}'::jsonb,
+                        CAST(:owner_uid AS uuid), TRUE, '{}'::jsonb,
+                        CAST(:actor_uid AS uuid)
+                    )
+                    """
+                ),
+                {
+                    "uid": plan_uid,
+                    "source_uid": source_uid,
+                    "name": "WP03 semantic mapping source",
+                    "owner_uid": owner_uid,
+                    "actor_uid": actor_uid,
+                },
+            )
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_runs (
+                        uid, plan_uid, batch_key, status, attempt_count,
+                        cursor_before, cursor_after, statistics, actor_uid,
+                        started_at, finished_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:plan_uid AS uuid), :batch_key,
+                        'completed', 1, '{}'::jsonb, '{}'::jsonb,
+                        '{}'::jsonb, CAST(:actor_uid AS uuid),
+                        CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+                    )
+                    """
+                ),
+                {
+                    "uid": run_uid,
+                    "plan_uid": plan_uid,
+                    "batch_key": f"wp03-{run_uid}",
+                    "actor_uid": actor_uid,
+                },
+            )
+            snapshot = {
+                "name": "customers",
+                "namespace": "public",
+                "asset_type": "table",
+                "fields": [{"name": "customer_level", "data_type": "varchar"}],
+            }
+            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,
+                        'public', 'customers', 'table', 'active', 1,
+                        :content_hash, CAST(:snapshot AS jsonb), '{}'::jsonb,
+                        CAST(:run_uid AS uuid)
+                    )
+                    """
+                ),
+                {
+                    "uid": physical_asset_uid,
+                    "source_uid": source_uid,
+                    "asset_key": f"{source_uid}:public.customers",
+                    "content_hash": "a" * 64,
+                    "snapshot": json.dumps(snapshot),
+                    "run_uid": run_uid,
+                },
+            )
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.data_elements (
+                        uid, code, current_version, status,
+                        business_domain_uids, created_by
+                    ) VALUES (
+                        CAST(:uid AS uuid), :code, 1, 'published',
+                        CAST(:domains AS jsonb), :created_by
+                    );
+                    INSERT INTO public.data_element_versions (
+                        uid, data_element_uid, version, status, snapshot,
+                        evidence_uids, created_by
+                    ) VALUES (
+                        CAST(:version_uid AS uuid), CAST(:uid AS uuid), 1,
+                        'published', CAST(:snapshot AS jsonb), '[]'::jsonb,
+                        :created_by
+                    )
+                    """
+                ),
+                {
+                    "uid": element_uid,
+                    "version_uid": element_version_uid,
+                    "code": f"CUSTOMER_LEVEL_{element_uid[:8]}",
+                    "domains": json.dumps([source_uid]),
+                    "created_by": actor_uid,
+                    "snapshot": json.dumps({"name": "客户等级"}),
+                },
+            )
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.data_standards (
+                        id, standard_uid, name, owner_uid, status
+                    ) VALUES (
+                        CAST(:id AS uuid), CAST(:standard_uid AS uuid),
+                        '客户主数据标准', CAST(:owner_uid AS uuid), 'active'
+                    );
+                    INSERT INTO public.data_standard_versions (
+                        id, standard_uid, version_no, source_text,
+                        standard_spec, spec_hash, scope, status, created_by,
+                        published_at
+                    ) VALUES (
+                        CAST(:version_uid AS uuid),
+                        CAST(:standard_uid AS uuid), 1, '客户等级必须使用统一代码',
+                        '{}'::jsonb, :spec_hash, '{}'::jsonb, 'validated',
+                        CAST(:actor_uid AS uuid), NULL
+                    )
+                    """
+                ),
+                {
+                    "id": standard_id,
+                    "standard_uid": standard_uid,
+                    "version_uid": standard_version_uid,
+                    "owner_uid": owner_uid,
+                    "actor_uid": actor_uid,
+                    "spec_hash": "b" * 64,
+                },
+            )
+            db.session.commit()
+
+            standard = DataRuleRepository(db.session).publish_standard_version(
+                version_id=standard_version_uid,
+                published_by=actor_uid,
+            )
+            db.session.commit()
+            assert standard["status"] == "published"
+
+            repository = SqlAlchemySemanticGovernanceRepository(db.session)
+            service = SemanticGovernanceService(
+                repository,
+                outbox_enqueue=lambda **event: enqueue_outbox(
+                    db.session, **event
+                ),
+            )
+
+            code_set = service.create_draft(
+                "code_set",
+                {
+                    "code": f"CUSTOMER_LEVEL_{source_uid[:8]}",
+                    "name": "客户等级代码集",
+                    "owner_uid": owner_uid,
+                    "business_domain_uid": source_uid,
+                    "definition": "客户等级统一参考数据",
+                    "values": [
+                        {"code": "VIP", "name": "重要客户"},
+                        {
+                            "code": "VIP_A",
+                            "name": "A级重要客户",
+                            "parent_code": "VIP",
+                        },
+                    ],
+                    "standard_version_uids": [standard_version_uid],
+                },
+                actor_uid=actor_uid,
+            )
+            semantic_uids.append(code_set["uid"])
+            service.submit(code_set["uid"], expected_version=1, actor_uid=actor_uid)
+            service.review(
+                code_set["uid"],
+                expected_version=1,
+                decision="approve",
+                reason="参考数据责任人确认",
+                actor_uid=reviewer_uid,
+            )
+            service.publish(
+                code_set["uid"], expected_version=1, actor_uid=reviewer_uid
+            )
+
+            term = service.create_draft(
+                "business_term",
+                {
+                    "code": f"TERM_CUSTOMER_LEVEL_{source_uid[:8]}",
+                    "name": "客户等级",
+                    "owner_uid": owner_uid,
+                    "business_domain_uid": source_uid,
+                    "definition": "客户运营分层等级",
+                    "aliases": ["客群等级", "客户分层"],
+                    "related_asset_uids": [physical_asset_uid],
+                    "related_data_element_uids": [element_uid],
+                    "standard_version_uids": [standard_version_uid],
+                    "code_references": [
+                        {"code_set_uid": code_set["uid"], "code": "VIP"}
+                    ],
+                },
+                actor_uid=actor_uid,
+            )
+            semantic_uids.append(term["uid"])
+            service.submit(term["uid"], expected_version=1, actor_uid=actor_uid)
+            service.review(
+                term["uid"],
+                expected_version=1,
+                decision="approve",
+                reason="术语定义审核通过",
+                actor_uid=reviewer_uid,
+            )
+            service.publish(term["uid"], expected_version=1, actor_uid=reviewer_uid)
+
+            metric_payload = {
+                "code": f"METRIC_VIP_RATE_{source_uid[:8]}",
+                "name": "重要客户占比",
+                "owner_uid": owner_uid,
+                "business_domain_uid": source_uid,
+                "definition": "重要客户数占全部客户数的比例",
+                "formula": "VIP客户数 / 全部客户数",
+                "unit": "%",
+                "dimensions": [{"code": "region", "name": "区域"}],
+                "related_asset_uids": [physical_asset_uid],
+                "standard_version_uids": [standard_version_uid],
+            }
+            metric = service.create_draft(
+                "metric", metric_payload, actor_uid=actor_uid
+            )
+            semantic_uids.append(metric["uid"])
+            for semantic_uid in (metric["uid"],):
+                service.submit(
+                    semantic_uid, expected_version=1, actor_uid=actor_uid
+                )
+                service.review(
+                    semantic_uid,
+                    expected_version=1,
+                    decision="approve",
+                    reason="指标口径审核通过",
+                    actor_uid=reviewer_uid,
+                )
+                service.publish(
+                    semantic_uid,
+                    expected_version=1,
+                    actor_uid=reviewer_uid,
+                )
+
+            mapping = service.map_physical_field(
+                {
+                    "asset_uid": physical_asset_uid,
+                    "field_name": "customer_level",
+                    "data_element_uid": element_uid,
+                    "owner_uid": owner_uid,
+                    "evidence": {"source": "catalog"},
+                },
+                actor_uid=actor_uid,
+            )
+            revised = service.revise(
+                metric["uid"],
+                {**metric_payload, "formula": "VIP客户数 / 有效客户数"},
+                expected_version=1,
+                reason="排除无效客户",
+                actor_uid=actor_uid,
+            )
+            service.submit(
+                metric["uid"],
+                expected_version=2,
+                actor_uid=actor_uid,
+            )
+            service.review(
+                metric["uid"],
+                expected_version=2,
+                decision="approve",
+                reason="口径修订审核通过",
+                actor_uid=reviewer_uid,
+            )
+            service.publish(
+                metric["uid"],
+                expected_version=2,
+                actor_uid=reviewer_uid,
+            )
+            rolled_back = service.rollback(
+                metric["uid"],
+                target_version=1,
+                expected_version=2,
+                reason="恢复已验收口径",
+                actor_uid=reviewer_uid,
+            )
+            db.session.commit()
+
+            assert mapping["data_element_version"] == 1
+            assert revised["current_version"] == 2
+            assert rolled_back["current_version"] == 3
+            assert rolled_back["definition"]["formula"] == (
+                "VIP客户数 / 全部客户数"
+            )
+            assert [
+                item["status"]
+                for item in service.list_versions(metric["uid"])
+            ] == ["superseded", "superseded", "published"]
+            assert len(service.list_audits(metric["uid"])) == 9
+            event_count = db.session.execute(
+                text(
+                    """
+                    SELECT count(*) FROM public.outbox_events
+                    WHERE aggregate_type = 'semantic_asset'
+                      AND aggregate_id = :aggregate_id
+                      AND event_type = 'semantic_asset.version_published'
+                    """
+                ),
+                {"aggregate_id": metric["uid"]},
+            ).scalar_one()
+            assert int(event_count) == 3
+            standard_event_count = db.session.execute(
+                text(
+                    """
+                    SELECT count(*) FROM public.outbox_events
+                    WHERE aggregate_type = 'data_standard'
+                      AND aggregate_id = :aggregate_id
+                      AND event_type = 'data_standard.version_published'
+                    """
+                ),
+                {"aggregate_id": standard_uid},
+            ).scalar_one()
+            assert int(standard_event_count) == 1
+            claimed = claim_outbox(
+                db.session,
+                limit=10,
+                event_types=["data_standard.version_published"],
+            )
+            assert len(claimed) == 1
+            assert claimed[0].event_type == "data_standard.version_published"
+            db.session.rollback()
+    finally:
+        with app.app_context():
+            db.session.rollback()
+            if semantic_uids:
+                params = {"uids": semantic_uids}
+                db.session.execute(
+                    text(
+                        "DELETE FROM public.outbox_events "
+                        "WHERE aggregate_type = 'semantic_asset' "
+                        "AND aggregate_id = ANY(:uids)"
+                    ),
+                    params,
+                )
+                for table in (
+                    "semantic_asset_reviews",
+                    "semantic_asset_links",
+                    "semantic_asset_versions",
+                ):
+                    db.session.execute(
+                        text(
+                            f"DELETE FROM public.{table} "
+                            "WHERE asset_uid = ANY(CAST(:uids AS uuid[]))"
+                        ),
+                        params,
+                    )
+                db.session.execute(
+                    text(
+                        "DELETE FROM public.semantic_publication_audits "
+                        "WHERE target_uid = ANY(CAST(:uids AS uuid[]))"
+                    ),
+                    params,
+                )
+                db.session.execute(
+                    text(
+                        "DELETE FROM public.semantic_assets "
+                        "WHERE uid = ANY(CAST(:uids AS uuid[]))"
+                    ),
+                    params,
+                )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.semantic_publication_audits "
+                    "WHERE target_type = 'field_mapping' "
+                    "AND target_uid IN ("
+                    "SELECT uid FROM public.data_element_field_mappings "
+                    "WHERE asset_uid = CAST(:asset_uid AS uuid))"
+                ),
+                {"asset_uid": physical_asset_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.data_element_field_mappings "
+                    "WHERE asset_uid = CAST(:asset_uid AS uuid)"
+                ),
+                {"asset_uid": physical_asset_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.outbox_events "
+                    "WHERE aggregate_type = 'data_standard' "
+                    "AND aggregate_id = :aggregate_id"
+                ),
+                {"aggregate_id": standard_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.semantic_publication_audits "
+                    "WHERE target_type = 'data_standard' "
+                    "AND target_uid = CAST(:target_uid AS uuid)"
+                ),
+                {"target_uid": standard_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.data_standard_versions "
+                    "WHERE id = CAST(:uid AS uuid)"
+                ),
+                {"uid": standard_version_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.data_standards "
+                    "WHERE id = CAST(:uid AS uuid)"
+                ),
+                {"uid": standard_id},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.data_element_versions "
+                    "WHERE data_element_uid = CAST(:uid AS uuid)"
+                ),
+                {"uid": element_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.data_elements "
+                    "WHERE uid = CAST(:uid AS uuid)"
+                ),
+                {"uid": element_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.active_metadata_assets "
+                    "WHERE uid = CAST(:uid AS uuid)"
+                ),
+                {"uid": physical_asset_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.active_metadata_runs "
+                    "WHERE uid = CAST(:uid AS uuid)"
+                ),
+                {"uid": run_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.active_metadata_plans "
+                    "WHERE uid = CAST(:uid AS uuid)"
+                ),
+                {"uid": plan_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.ingestion_sources "
+                    "WHERE uid = CAST(:uid AS uuid)"
+                ),
+                {"uid": source_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.users "
+                    "WHERE id = ANY(CAST(:uids AS uuid[]))"
+                ),
+                {"uids": [actor_uid, owner_uid, reviewer_uid]},
+            )
+            db.session.commit()

+ 84 - 0
tests/knowledge/test_point_builder.py

@@ -119,3 +119,87 @@ def test_same_semantic_source_produces_same_snapshot_hash():
     )
     assert first.source_snapshot_hash == second.source_snapshot_hash
     assert first.point_set_hash == second.point_set_hash
+
+
+def test_semantic_assets_build_versioned_knowledge_points():
+    from app.core.knowledge.point_builder import build_knowledge_snapshot
+
+    term_uid = "01900000-0000-7000-8000-000000000601"
+    metric_uid = "01900000-0000-7000-8000-000000000602"
+    term = build_knowledge_snapshot(
+        "BusinessTerm",
+        {
+            "uid": term_uid,
+            "version": 3,
+            "name": "物料",
+            "definition": "统一采购、库存和维修语义。",
+            "aliases": ["备件", "物资"],
+            "business_domain_uid": "01900000-0000-7000-8000-000000000603",
+        },
+    )
+    metric = build_knowledge_snapshot(
+        "MetricDefinition",
+        {
+            "uid": metric_uid,
+            "version": 2,
+            "name": "物料完整率",
+            "definition": "完整物料占全部物料的比例。",
+            "formula": "完整物料数 / 全部物料数",
+            "unit": "%",
+            "dimensions": [
+                {
+                    "uid": "category",
+                    "name": "物料分类",
+                    "definition": "按物料分类观察。",
+                }
+            ],
+        },
+    )
+
+    assert term.source_type == "BusinessTerm"
+    assert f"BusinessTerm/{term_uid}/aliases/" in "\n".join(
+        point.point_key for point in term.points
+    )
+    metric_points = _point_map(metric)
+    assert metric_points[f"MetricDefinition/{metric_uid}/formula"].content == (
+        "完整物料数 / 全部物料数"
+    )
+    assert (
+        f"MetricDefinition/{metric_uid}/dimensions/category/definition"
+        in metric_points
+    )
+
+
+def test_ontology_builds_stable_class_and_property_points():
+    from app.core.knowledge.point_builder import build_knowledge_snapshot
+
+    snapshot = build_knowledge_snapshot(
+        "Ontology",
+        {
+            "uid": "01900000-0000-7000-8000-000000000901",
+            "version": 2,
+            "name": "客户本体",
+            "classes": [
+                {
+                    "uid": "01900000-0000-7000-8000-000000000902",
+                    "name": "客户",
+                    "definition": "企业服务对象",
+                }
+            ],
+            "properties": [
+                {
+                    "uid": "01900000-0000-7000-8000-000000000903",
+                    "name": "客户等级",
+                    "data_type": "string",
+                }
+            ],
+        },
+    )
+
+    assert snapshot.source_type == "Ontology"
+    point_keys = {point.point_key for point in snapshot.points}
+    assert any("classes/" in key and key.endswith("/name") for key in point_keys)
+    assert any(
+        "properties/" in key and key.endswith("/data_type")
+        for key in point_keys
+    )

+ 40 - 3
tests/test_outbox.py

@@ -1,9 +1,8 @@
 from __future__ import annotations
 
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
 from pathlib import Path
 
-
 ROOT = Path(__file__).resolve().parents[1]
 
 
@@ -43,7 +42,7 @@ def test_successful_dispatch_records_idempotent_completion():
 def test_failed_dispatch_retries_then_enters_dead_letter():
     from app.core.events.consumer import ClaimedEvent, dispatch_event
 
-    now = datetime(2026, 7, 16, tzinfo=timezone.utc)
+    now = datetime(2026, 7, 16, tzinfo=UTC)
 
     def fail(_payload):
         raise RuntimeError("downstream unavailable with secret-token")
@@ -90,6 +89,7 @@ def test_outbox_migration_and_claim_query_define_delivery_contract():
         assert column in migration
     assert "outbox_consumptions" in migration
     assert "FOR UPDATE SKIP LOCKED" in repository
+    assert "event_type = ANY" in repository
     assert "outbox_health" in repository
 
 
@@ -110,3 +110,40 @@ def test_consumer_without_registered_handlers_does_not_claim_events(monkeypatch)
     monkeypatch.setattr(process_outbox, "claim_outbox", unexpected_claim)
 
     assert process_outbox.process_available_events(object(), handlers={}) == 0
+
+
+def test_default_consumer_registers_all_governance_publication_sync(monkeypatch):
+    from app.commands import process_outbox
+
+    handled = []
+
+    def semantic(payload):
+        handled.append(("semantic", payload))
+
+    monkeypatch.setattr(
+        process_outbox,
+        "build_governance_knowledge_handlers",
+        lambda app, session: {
+            "semantic_asset.version_published": semantic,
+            "data_standard.version_published": (
+                lambda payload: handled.append(("standard", payload))
+            ),
+            "ontology.version_published": (
+                lambda payload: handled.append(("ontology", payload))
+            ),
+        },
+    )
+
+    handlers = process_outbox.build_default_handlers(
+        object(),
+        object(),
+    )
+    handlers["semantic_asset.version_published"]({"uid": "semantic-1"})
+    handlers["data_standard.version_published"]({"uid": "standard-1"})
+    handlers["ontology.version_published"]({"uid": "ontology-1"})
+
+    assert handled == [
+        ("semantic", {"uid": "semantic-1"}),
+        ("standard", {"uid": "standard-1"}),
+        ("ontology", {"uid": "ontology-1"}),
+    ]

+ 38 - 0
tests/test_phase2_wp03_migration_contract.py

@@ -0,0 +1,38 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_semantic_governance_migration_is_additive_versioned_and_linked():
+    migration = (
+        ROOT / "migrations/versions/20260731_390_semantic_governance.py"
+    ).read_text(encoding="utf-8")
+
+    assert 'revision = "20260731_390"' in migration
+    assert 'down_revision = "20260730_380"' in migration
+    for table in (
+        "semantic_assets",
+        "semantic_asset_versions",
+        "semantic_asset_reviews",
+        "semantic_asset_links",
+        "data_element_field_mappings",
+        "semantic_publication_audits",
+    ):
+        assert f"CREATE TABLE public.{table}" in migration
+    assert "UNIQUE (asset_kind, code)" in migration
+    assert "UNIQUE (asset_uid, version)" in migration
+    assert "REFERENCES public.active_metadata_assets(uid)" in migration
+    assert "REFERENCES public.data_elements(uid)" in migration
+    assert "rollback_from_version" in migration
+    assert "DROP TABLE" not in migration.upper()
+    assert "DELETE FROM public" not in migration
+
+
+def test_existing_standard_publication_emits_audited_knowledge_event():
+    repository = (
+        ROOT / "app/core/data_rules/repository.py"
+    ).read_text(encoding="utf-8")
+
+    assert "data_standard.version_published" in repository
+    assert "semantic_publication_audits" in repository
+    assert 'aggregate_type="data_standard"' in repository