Bläddra i källkod

feat: add device semantic governance

马小龙 3 månader sedan
förälder
incheckning
c2ff54aaf8
34 ändrade filer med 6237 tillägg och 27 borttagningar
  1. 311 0
      app/api/data_development/routes.py
  2. 262 0
      app/core/data_research/device_semantic_repository.py
  3. 969 0
      app/core/data_research/device_semantics.py
  4. 20 0
      app/core/data_research/errors.py
  5. 3 0
      app/core/data_research/ontology/publication.py
  6. 26 0
      app/core/data_research/ontology/repository.py
  7. 11 0
      app/core/system/permissions.py
  8. 148 0
      app/models/data_research.py
  9. 311 0
      deployment/app/api/data_development/routes.py
  10. 262 0
      deployment/app/core/data_research/device_semantic_repository.py
  11. 969 0
      deployment/app/core/data_research/device_semantics.py
  12. 20 0
      deployment/app/core/data_research/errors.py
  13. 3 0
      deployment/app/core/data_research/ontology/publication.py
  14. 26 0
      deployment/app/core/data_research/ontology/repository.py
  15. 27 12
      deployment/app/core/system/permissions.py
  16. 148 0
      deployment/app/models/data_research.py
  17. 1 0
      docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md
  18. 13 13
      docs/FUNCTION_MODULE_CENSUS_20260726.md
  19. 5 0
      docs/architecture/DATA_MODEL.md
  20. 261 1
      docs/architecture/OPENAPI.yaml
  21. 208 0
      docs/superpowers/plans/2026-07-29-wp05-device-semantics-ontology.md
  22. 44 1
      frontend/src/api/dataDevelopment.js
  23. 27 0
      frontend/src/router/routes.js
  24. 834 0
      frontend/src/views/dataGovernance/ontology/deviceSemantics.vue
  25. 62 0
      frontend/src/views/dataGovernance/ontology/deviceSemanticsModel.js
  26. 8 0
      frontend/src/views/dataGovernance/ontology/index.vue
  27. 51 0
      frontend/tests/device-semantics-model.test.mjs
  28. 91 0
      migrations/versions/20260729_300_device_semantics.py
  29. 315 0
      tests/data_research/test_device_semantic_api.py
  30. 581 0
      tests/data_research/test_device_semantics.py
  31. 24 0
      tests/data_research/test_ontology_publication.py
  32. 152 0
      tests/integration/test_device_semantics_postgres.py
  33. 27 0
      tests/test_database_migrations.py
  34. 17 0
      tests/test_permission_matrix.py

+ 311 - 0
app/api/data_development/routes.py

@@ -114,6 +114,58 @@ def get_device_asset_service():
     )
 
 
+def _device_ontology_authorizer(ontology_repository):
+    from app.core.data_research.device_semantics import (
+        DeviceOntologyPublicationAuthorizer,
+    )
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+    return DeviceOntologyPublicationAuthorizer(
+        ontology_repository,
+        responsibility_lookup=responsibilities.get,
+    )
+
+
+def get_device_semantic_service():
+    from app.core.data_research.device_semantics import DeviceSemanticService
+    from app.core.data_research.ontology.repository import (
+        SqlAlchemyOntologyRepository,
+    )
+
+    return DeviceSemanticService(
+        SqlAlchemyOntologyRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_device_semantic_code_service():
+    from app.core.data_research.device_semantic_repository import (
+        SqlAlchemyDeviceSemanticCodeRepository,
+    )
+    from app.core.data_research.device_semantics import (
+        DeviceSemanticCodeService,
+    )
+    from app.core.data_research.ontology.repository import (
+        SqlAlchemyOntologyRepository,
+    )
+
+    ontology_repository = SqlAlchemyOntologyRepository(db.session)
+    authorizer = _device_ontology_authorizer(ontology_repository)
+    return DeviceSemanticCodeService(
+        SqlAlchemyDeviceSemanticCodeRepository(db.session),
+        review_authorizer=authorizer.assert_accountable,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
 def get_candidate_decision_service():
     from app.core.data_research.candidate_decisions import CandidateDecisionService
     from app.core.data_research.data_elements import DataElementService
@@ -176,9 +228,11 @@ def get_ontology_service():
     from app.core.events.outbox import enqueue_outbox
 
     repository = SqlAlchemyOntologyRepository(db.session)
+    authorizer = _device_ontology_authorizer(repository)
     publication = OntologyPublicationService(
         repository,
         outbox_enqueue=lambda **event: enqueue_outbox(db.session, **event),
+        publication_authorizer=authorizer,
         commit=db.session.commit,
         rollback=db.session.rollback,
     )
@@ -402,6 +456,86 @@ def _device_asset_import_result(result):
     }
 
 
+def _device_semantic_profile(result):
+    if result is None:
+        return {
+            "bootstrapped": False,
+            "ontology": None,
+            "version": None,
+            "profile": {
+                "ready_to_publish": False,
+                "class_count": 0,
+                "relation_count": 0,
+                "mapping_count": 0,
+                "missing_classes": [],
+                "missing_relations": [],
+                "missing_mappings": [],
+            },
+        }
+    profile = result.profile
+    return {
+        "bootstrapped": True,
+        "created": bool(result.created),
+        "ontology": _ontology(result.ontology),
+        "version": _ontology_version(result.version),
+        "profile": {
+            "ready_to_publish": bool(profile.ready_to_publish),
+            "class_count": int(profile.class_count),
+            "relation_count": int(profile.relation_count),
+            "mapping_count": int(profile.mapping_count),
+            "missing_classes": list(profile.missing_classes),
+            "missing_relations": list(profile.missing_relations),
+            "missing_mappings": list(profile.missing_mappings),
+        },
+    }
+
+
+def _device_semantic_code(record):
+    return {
+        "uid": str(record.uid),
+        "ontology_uid": str(record.ontology_uid),
+        "code_type": record.code_type,
+        "canonical_code": record.canonical_code,
+        "canonical_name": record.canonical_name,
+        "definition": record.definition,
+        "status": record.status,
+        "current_version": int(record.current_version),
+        "source_mappings": [
+            dict(item) for item in record.source_mappings
+        ],
+        "evidence_uids": list(record.evidence_uids),
+        "suggestion_source": record.suggestion_source,
+        "confidence": record.confidence,
+        "created_by": record.created_by,
+        "updated_by": record.updated_by,
+        "created_at": _iso(record.created_at),
+        "updated_at": _iso(record.updated_at),
+    }
+
+
+def _device_semantic_version(record):
+    return {
+        "uid": str(record.uid),
+        "code_uid": str(record.code_uid),
+        "version": int(record.version),
+        "snapshot": dict(record.snapshot),
+        "created_by": record.created_by,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_semantic_review(record):
+    return {
+        "uid": str(record.uid),
+        "code_uid": str(record.code_uid),
+        "version": int(record.version),
+        "decision": record.decision,
+        "reason": record.reason,
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
 def _device_asset_page(name, *, default, maximum):
     from app.core.data_research.errors import DeviceAssetInvalid
 
@@ -654,6 +788,183 @@ def list_device_asset_versions(asset_uid):
         return _error(error)
 
 
+@bp.route("/device-semantics/bootstrap", methods=["POST"])
+def bootstrap_device_semantics():
+    try:
+        result = get_device_semantic_service().bootstrap(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return (
+            jsonify(success(_device_semantic_profile(result))),
+            201 if result.created else 200,
+        )
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/profile", methods=["GET"])
+def get_device_semantic_profile():
+    try:
+        result = get_device_semantic_service().profile()
+        return jsonify(success(_device_semantic_profile(result))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/codes", methods=["GET"])
+def list_device_semantic_codes():
+    filters = {
+        name: request.args.get(name)
+        for name in ("ontology_uid", "code_type", "status", "keyword")
+        if request.args.get(name)
+    }
+    try:
+        records, total = get_device_semantic_code_service().search(
+            filters,
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_semantic_code(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(request.args.get("page", 1)),
+                    "page_size": int(request.args.get("page_size", 20)),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/codes", methods=["POST"])
+def create_device_semantic_code():
+    try:
+        record = get_device_semantic_code_service().create(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_semantic_code(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/codes/<code_uid>", methods=["GET"])
+def get_device_semantic_code(code_uid):
+    try:
+        record = get_device_semantic_code_service().get(code_uid)
+        return jsonify(success(_device_semantic_code(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/revisions",
+    methods=["POST"],
+)
+def revise_device_semantic_code(code_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_device_semantic_code_service().revise(
+            code_uid,
+            payload,
+            expected_version=payload.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_semantic_code(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/submit",
+    methods=["POST"],
+)
+def submit_device_semantic_code(code_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_device_semantic_code_service().submit(
+            code_uid,
+            expected_version=payload.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_semantic_code(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/review",
+    methods=["POST"],
+)
+def review_device_semantic_code(code_uid):
+    try:
+        record, review = get_device_semantic_code_service().review(
+            code_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(
+            success(
+                {
+                    "record": _device_semantic_code(record),
+                    "review": _device_semantic_review(review),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/versions",
+    methods=["GET"],
+)
+def list_device_semantic_code_versions(code_uid):
+    try:
+        records = get_device_semantic_code_service().versions(code_uid)
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_semantic_version(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/reviews",
+    methods=["GET"],
+)
+def list_device_semantic_code_reviews(code_uid):
+    try:
+        records = get_device_semantic_code_service().reviews(code_uid)
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_semantic_review(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

+ 262 - 0
app/core/data_research/device_semantic_repository.py

@@ -0,0 +1,262 @@
+from __future__ import annotations
+
+from sqlalchemy import func, or_
+
+from app.core.data_research.device_semantics import (
+    DeviceSemanticCodeRecord,
+    DeviceSemanticCodeReviewRecord,
+    DeviceSemanticCodeVersionRecord,
+)
+from app.models.data_research import (
+    DeviceSemanticCode,
+    DeviceSemanticCodeReview,
+    DeviceSemanticCodeVersion,
+    OntologyModel,
+)
+
+
+class SqlAlchemyDeviceSemanticCodeRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _record(model):
+        return DeviceSemanticCodeRecord(
+            uid=str(model.uid),
+            ontology_uid=str(model.ontology_uid),
+            code_type=model.code_type,
+            canonical_code=model.canonical_code,
+            canonical_name=model.canonical_name,
+            definition=model.definition,
+            status=model.status,
+            current_version=int(model.current_version),
+            source_mappings=tuple(
+                dict(item) for item in (model.source_mappings or ())
+            ),
+            evidence_uids=tuple(model.evidence_uids or ()),
+            suggestion_source=model.suggestion_source,
+            confidence=model.confidence,
+            created_by=model.created_by,
+            updated_by=model.updated_by,
+            created_at=model.created_at,
+            updated_at=model.updated_at,
+        )
+
+    @staticmethod
+    def _version_record(model):
+        return DeviceSemanticCodeVersionRecord(
+            uid=str(model.uid),
+            code_uid=str(model.code_uid),
+            version=int(model.version),
+            snapshot=dict(model.snapshot or {}),
+            created_by=model.created_by,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _review_record(model):
+        return DeviceSemanticCodeReviewRecord(
+            uid=str(model.uid),
+            code_uid=str(model.code_uid),
+            version=int(model.version),
+            decision=model.decision,
+            reason=model.reason,
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _model(record):
+        return DeviceSemanticCode(
+            uid=record.uid,
+            ontology_uid=record.ontology_uid,
+            code_type=record.code_type,
+            canonical_code=record.canonical_code,
+            canonical_name=record.canonical_name,
+            definition=record.definition,
+            status=record.status,
+            current_version=record.current_version,
+            source_mappings=list(record.source_mappings),
+            evidence_uids=list(record.evidence_uids),
+            suggestion_source=record.suggestion_source,
+            confidence=record.confidence,
+            created_by=record.created_by,
+            updated_by=record.updated_by,
+            created_at=record.created_at,
+            updated_at=record.updated_at,
+        )
+
+    @staticmethod
+    def _version_model(record):
+        return DeviceSemanticCodeVersion(
+            uid=record.uid,
+            code_uid=record.code_uid,
+            version=record.version,
+            snapshot=record.snapshot,
+            created_by=record.created_by,
+            created_at=record.created_at,
+        )
+
+    def ontology_exists(self, ontology_uid):
+        return (
+            self.session.query(OntologyModel.uid)
+            .filter_by(uid=str(ontology_uid))
+            .first()
+            is not None
+        )
+
+    def is_device_ontology(self, ontology_uid):
+        return (
+            self.session.query(OntologyModel.uid)
+            .filter_by(
+                uid=str(ontology_uid),
+                code="DEVICE_SEMANTIC",
+            )
+            .first()
+            is not None
+        )
+
+    def find_code(self, ontology_uid, code_type, canonical_code):
+        model = (
+            self.session.query(DeviceSemanticCode)
+            .filter_by(
+                ontology_uid=str(ontology_uid),
+                code_type=str(code_type),
+                canonical_code=str(canonical_code),
+            )
+            .first()
+        )
+        return self._record(model) if model is not None else None
+
+    def get_code(self, code_uid, *, for_update=False):
+        query = self.session.query(DeviceSemanticCode).filter_by(
+            uid=str(code_uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._record(model) if model is not None else None
+
+    def create_code(self, record, version):
+        model = self._model(record)
+        self.session.add(model)
+        self.session.add(self._version_model(version))
+        self.session.flush()
+        return self._record(model)
+
+    def revise_code(self, record, version):
+        model = self.session.get(DeviceSemanticCode, str(record.uid))
+        for field in (
+            "canonical_name",
+            "definition",
+            "status",
+            "current_version",
+            "source_mappings",
+            "evidence_uids",
+            "suggestion_source",
+            "confidence",
+            "updated_by",
+            "updated_at",
+        ):
+            value = getattr(record, field)
+            if field in {"source_mappings", "evidence_uids"}:
+                value = list(value)
+            setattr(model, field, value)
+        self.session.add(self._version_model(version))
+        self.session.flush()
+        return self._record(model)
+
+    def set_status(self, record, status, *, updated_at, updated_by):
+        model = self.session.get(DeviceSemanticCode, str(record.uid))
+        model.status = status
+        model.updated_at = updated_at
+        model.updated_by = updated_by
+        self.session.flush()
+        return self._record(model)
+
+    def review_code(self, record, review, status):
+        model = self.session.get(DeviceSemanticCode, str(record.uid))
+        review_model = DeviceSemanticCodeReview(
+            uid=review.uid,
+            code_uid=review.code_uid,
+            version=review.version,
+            decision=review.decision,
+            reason=review.reason,
+            actor_uid=review.actor_uid,
+            created_at=review.created_at,
+        )
+        model.status = status
+        model.updated_at = review.created_at
+        model.updated_by = review.actor_uid
+        self.session.add(review_model)
+        self.session.flush()
+        return self._record(model), self._review_record(review_model)
+
+    @staticmethod
+    def _filtered(query, filters):
+        if filters.get("ontology_uid"):
+            query = query.filter(
+                DeviceSemanticCode.ontology_uid
+                == str(filters["ontology_uid"])
+            )
+        if filters.get("code_type"):
+            query = query.filter(
+                DeviceSemanticCode.code_type == str(filters["code_type"])
+            )
+        if filters.get("status"):
+            query = query.filter(
+                DeviceSemanticCode.status == str(filters["status"])
+            )
+        if filters.get("keyword"):
+            pattern = f"%{filters['keyword']}%"
+            query = query.filter(
+                or_(
+                    DeviceSemanticCode.canonical_code.ilike(pattern),
+                    DeviceSemanticCode.canonical_name.ilike(pattern),
+                    DeviceSemanticCode.definition.ilike(pattern),
+                )
+            )
+        return query
+
+    def search(self, filters, *, page, page_size):
+        base = self._filtered(
+            self.session.query(DeviceSemanticCode),
+            filters,
+        )
+        total = base.with_entities(
+            func.count(DeviceSemanticCode.uid)
+        ).scalar() or 0
+        models = (
+            base.order_by(
+                DeviceSemanticCode.code_type.asc(),
+                DeviceSemanticCode.canonical_code.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._record(model) for model in models], int(total)
+
+    def list_versions(self, code_uid):
+        return [
+            self._version_record(model)
+            for model in self.session.query(DeviceSemanticCodeVersion)
+            .filter_by(code_uid=str(code_uid))
+            .order_by(DeviceSemanticCodeVersion.version.desc())
+            .all()
+        ]
+
+    def list_reviews(self, code_uid):
+        return [
+            self._review_record(model)
+            for model in self.session.query(DeviceSemanticCodeReview)
+            .filter_by(code_uid=str(code_uid))
+            .order_by(
+                DeviceSemanticCodeReview.created_at.desc(),
+                DeviceSemanticCodeReview.uid.desc(),
+            )
+            .all()
+        ]
+
+
+__all__ = ["SqlAlchemyDeviceSemanticCodeRepository"]

+ 969 - 0
app/core/data_research/device_semantics.py

@@ -0,0 +1,969 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+from app.core.data_research.errors import (
+    DeviceSemanticConflict,
+    DeviceSemanticForbidden,
+    DeviceSemanticInvalid,
+    DeviceSemanticNotFound,
+)
+
+DEVICE_ONTOLOGY_CODE = "DEVICE_SEMANTIC"
+REQUIRED_CLASS_UIDS = (
+    "action",
+    "alarm",
+    "cause",
+    "component",
+    "device",
+    "fault",
+    "location",
+    "maintenance_record",
+    "measurement_point",
+    "organization",
+    "person",
+)
+REQUIRED_RELATION_UIDS = (
+    "alarm.classified_as",
+    "alarm.raised_by",
+    "component.part_of",
+    "device.located_at",
+    "device.owned_by",
+    "device.responsible_to",
+    "fault.caused_by",
+    "maintenance_record.maintains",
+    "maintenance_record.uses_action",
+    "measurement_point.monitors",
+)
+REQUIRED_MAPPING_UIDS = (
+    "device.platform_uid",
+    "device.source_code",
+)
+CODE_TYPES = frozenset({"fault", "cause", "action"})
+CODE_STATUSES = frozenset(
+    {"draft", "in_review", "published", "rejected", "retired"}
+)
+SUGGESTION_SOURCES = frozenset({"manual", "rule", "ai"})
+SEMANTIC_SECRET_KEYS = frozenset(
+    {
+        "password",
+        "passwd",
+        "credential",
+        "credentials",
+        "token",
+        "api_key",
+        "authorization",
+        "connection_string",
+        "connection_url",
+        "encrypted_payload",
+        "phone",
+        "mobile",
+        "email",
+        "contact",
+        "contact_phone",
+        "contact_email",
+        "id_number",
+        "personal_address",
+    }
+)
+
+
+@dataclass(frozen=True)
+class DeviceSemanticProfile:
+    ready_to_publish: bool
+    class_count: int
+    relation_count: int
+    mapping_count: int
+    missing_classes: tuple[str, ...]
+    missing_relations: tuple[str, ...]
+    missing_mappings: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class DeviceSemanticBootstrapResult:
+    ontology: Any
+    version: Any
+    profile: DeviceSemanticProfile
+    created: bool
+
+
+@dataclass(frozen=True)
+class DeviceSemanticCodeRecord:
+    uid: str
+    ontology_uid: str
+    code_type: str
+    canonical_code: str
+    canonical_name: str
+    definition: str | None
+    status: str
+    current_version: int
+    source_mappings: tuple[dict[str, str], ...]
+    evidence_uids: tuple[str, ...]
+    suggestion_source: str
+    confidence: float | None
+    created_by: str | None
+    updated_by: str | None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceSemanticCodeVersionRecord:
+    uid: str
+    code_uid: str
+    version: int
+    snapshot: dict[str, Any]
+    created_by: str | None
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceSemanticCodeReviewRecord:
+    uid: str
+    code_uid: str
+    version: int
+    decision: str
+    reason: str
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(UUID(str(value)))
+    except (AttributeError, TypeError, ValueError) as exc:
+        raise DeviceSemanticInvalid(f"{field} must be a UUID") from exc
+
+
+def _required_text(
+    payload: dict[str, Any],
+    field: str,
+    *,
+    maximum: int,
+) -> str:
+    value = str(payload.get(field) or "").strip()
+    if not value:
+        raise DeviceSemanticInvalid(f"{field} is required")
+    if len(value) > maximum:
+        raise DeviceSemanticInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return value
+
+
+def _optional_text(
+    payload: dict[str, Any],
+    field: str,
+    *,
+    maximum: int,
+) -> str | None:
+    value = str(payload.get(field) or "").strip()
+    if not value:
+        return None
+    if len(value) > maximum:
+        raise DeviceSemanticInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return value
+
+
+def _reject_semantic_secrets(value: Any) -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if str(key).strip().casefold() in SEMANTIC_SECRET_KEYS:
+                raise DeviceSemanticInvalid(
+                    "secret values are not allowed in semantic codes"
+                )
+            _reject_semantic_secrets(item)
+    elif isinstance(value, (list, tuple)):
+        for item in value:
+            _reject_semantic_secrets(item)
+
+
+def _source_mappings(value: Any) -> tuple[dict[str, str], ...]:
+    raw_items = value or ()
+    if not isinstance(raw_items, (list, tuple)):
+        raise DeviceSemanticInvalid("source_mappings must be a list")
+    if len(raw_items) > 100:
+        raise DeviceSemanticInvalid(
+            "source_mappings exceed the 100 item boundary"
+        )
+    _reject_semantic_secrets(raw_items)
+    items = []
+    identities = set()
+    for raw in raw_items:
+        if not isinstance(raw, dict):
+            raise DeviceSemanticInvalid(
+                "source mapping must be an object"
+            )
+        item = {
+            "source_uid": _uuid(raw.get("source_uid"), "source_uid"),
+            "source_code": _required_text(
+                raw,
+                "source_code",
+                maximum=300,
+            ),
+        }
+        identity = (item["source_uid"], item["source_code"])
+        if identity in identities:
+            raise DeviceSemanticInvalid("duplicate source mapping")
+        identities.add(identity)
+        items.append(item)
+    return tuple(items)
+
+
+def _evidence(value: Any) -> tuple[str, ...]:
+    raw_items = value or ()
+    if not isinstance(raw_items, (list, tuple)):
+        raise DeviceSemanticInvalid("evidence_uids must be a list")
+    if len(raw_items) > 100:
+        raise DeviceSemanticInvalid(
+            "evidence_uids exceed the 100 item boundary"
+        )
+    items = tuple(str(item).strip() for item in raw_items)
+    if any(not item or len(item) > 120 for item in items):
+        raise DeviceSemanticInvalid("evidence uid is invalid")
+    if len(set(items)) != len(items):
+        raise DeviceSemanticInvalid("duplicate evidence uid")
+    return items
+
+
+def _proposal(payload: dict[str, Any]):
+    suggestion_source = str(
+        payload.get("suggestion_source") or "manual"
+    ).strip()
+    if suggestion_source not in SUGGESTION_SOURCES:
+        raise DeviceSemanticInvalid("suggestion_source is not supported")
+    evidence_uids = _evidence(payload.get("evidence_uids"))
+    if suggestion_source != "manual" and not evidence_uids:
+        raise DeviceSemanticInvalid(
+            "rule and AI suggestions require evidence"
+        )
+    raw_confidence = payload.get("confidence")
+    confidence = None
+    if raw_confidence not in (None, ""):
+        try:
+            confidence = float(raw_confidence)
+        except (TypeError, ValueError) as exc:
+            raise DeviceSemanticInvalid(
+                "confidence must be numeric"
+            ) from exc
+        if confidence < 0 or confidence > 1:
+            raise DeviceSemanticInvalid(
+                "confidence must be between 0 and 1"
+            )
+    if suggestion_source == "ai" and confidence is None:
+        raise DeviceSemanticInvalid(
+            "AI suggestion confidence is required"
+        )
+    return suggestion_source, evidence_uids, confidence
+
+
+def _semantic_snapshot(
+    payload: dict[str, Any],
+    *,
+    existing: DeviceSemanticCodeRecord | None = None,
+):
+    if not isinstance(payload, dict):
+        raise DeviceSemanticInvalid("payload must be an object")
+    _reject_semantic_secrets(payload)
+    if existing is None:
+        ontology_uid = _uuid(
+            payload.get("ontology_uid"),
+            "ontology_uid",
+        )
+        code_type = str(payload.get("code_type") or "").strip()
+        if code_type not in CODE_TYPES:
+            raise DeviceSemanticInvalid("code_type is not supported")
+        canonical_code = _required_text(
+            payload,
+            "canonical_code",
+            maximum=120,
+        ).upper()
+    else:
+        ontology_uid = existing.ontology_uid
+        code_type = existing.code_type
+        canonical_code = existing.canonical_code
+    effective_payload = dict(payload)
+    if existing is not None:
+        effective_payload.setdefault(
+            "canonical_name",
+            existing.canonical_name,
+        )
+        effective_payload.setdefault("definition", existing.definition)
+    suggestion_source, evidence_uids, confidence = _proposal(
+        {
+            "suggestion_source": payload.get(
+                "suggestion_source",
+                existing.suggestion_source if existing else "manual",
+            ),
+            "evidence_uids": payload.get(
+                "evidence_uids",
+                list(existing.evidence_uids) if existing else [],
+            ),
+            "confidence": payload.get(
+                "confidence",
+                existing.confidence if existing else None,
+            ),
+        }
+    )
+    source_mappings = _source_mappings(
+        payload.get(
+            "source_mappings",
+            list(existing.source_mappings) if existing else [],
+        )
+    )
+    snapshot = {
+        "ontology_uid": ontology_uid,
+        "code_type": code_type,
+        "canonical_code": canonical_code,
+        "canonical_name": _required_text(
+            effective_payload,
+            "canonical_name",
+            maximum=300,
+        ),
+        "definition": _optional_text(
+            effective_payload,
+            "definition",
+            maximum=2000,
+        ),
+        "source_mappings": [dict(item) for item in source_mappings],
+        "evidence_uids": list(evidence_uids),
+        "suggestion_source": suggestion_source,
+        "confidence": confidence,
+    }
+    return snapshot
+
+
+def _version(value: Any, field: str = "expected_version") -> int:
+    try:
+        version = int(value)
+    except (TypeError, ValueError) as exc:
+        raise DeviceSemanticInvalid(f"{field} must be an integer") from exc
+    if version < 0:
+        raise DeviceSemanticInvalid(f"{field} must not be negative")
+    return version
+
+
+def _record_from_snapshot(
+    *,
+    uid: str,
+    snapshot: dict[str, Any],
+    status: str,
+    current_version: int,
+    actor_uid: str | None,
+    created_at: datetime,
+    updated_at: datetime,
+    created_by: str | None = None,
+) -> DeviceSemanticCodeRecord:
+    return DeviceSemanticCodeRecord(
+        uid=uid,
+        ontology_uid=snapshot["ontology_uid"],
+        code_type=snapshot["code_type"],
+        canonical_code=snapshot["canonical_code"],
+        canonical_name=snapshot["canonical_name"],
+        definition=snapshot["definition"],
+        status=status,
+        current_version=current_version,
+        source_mappings=tuple(
+            dict(item) for item in snapshot["source_mappings"]
+        ),
+        evidence_uids=tuple(snapshot["evidence_uids"]),
+        suggestion_source=snapshot["suggestion_source"],
+        confidence=snapshot["confidence"],
+        created_by=created_by if created_by is not None else actor_uid,
+        updated_by=actor_uid,
+        created_at=created_at,
+        updated_at=updated_at,
+    )
+
+
+class DeviceSemanticCodeService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        review_authorizer: Callable[[str, str], Any] = (
+            lambda _ontology_uid, _actor_uid: None
+        ),
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.review_authorizer = review_authorizer
+        self.commit = commit
+        self.rollback = rollback
+
+    @staticmethod
+    def _assert_expected_version(record, expected_version):
+        expected = _version(expected_version)
+        if record.current_version != expected:
+            raise DeviceSemanticConflict(
+                "semantic code version has changed"
+            )
+
+    def _get(self, code_uid, *, for_update=False):
+        uid = _uuid(code_uid, "code_uid")
+        record = self.repository.get_code(uid, for_update=for_update)
+        if record is None:
+            raise DeviceSemanticNotFound("semantic code was not found")
+        return record
+
+    def create(self, payload, actor_uid):
+        snapshot = _semantic_snapshot(payload)
+        if not self.repository.ontology_exists(snapshot["ontology_uid"]):
+            raise DeviceSemanticNotFound("ontology was not found")
+        if not self.repository.is_device_ontology(
+            snapshot["ontology_uid"]
+        ):
+            raise DeviceSemanticInvalid(
+                "semantic codes must belong to the device ontology"
+            )
+        existing = self.repository.find_code(
+            snapshot["ontology_uid"],
+            snapshot["code_type"],
+            snapshot["canonical_code"],
+        )
+        if existing is not None:
+            raise DeviceSemanticConflict("semantic code already exists")
+        created_at = self.now_factory()
+        code_uid = self.uid_factory()
+        record = _record_from_snapshot(
+            uid=code_uid,
+            snapshot=snapshot,
+            status="draft",
+            current_version=1,
+            actor_uid=actor_uid,
+            created_at=created_at,
+            updated_at=created_at,
+        )
+        version = DeviceSemanticCodeVersionRecord(
+            uid=self.uid_factory(),
+            code_uid=code_uid,
+            version=1,
+            snapshot=dict(snapshot),
+            created_by=actor_uid,
+            created_at=created_at,
+        )
+        try:
+            created = self.repository.create_code(record, version)
+            self.commit()
+            return created
+        except Exception:
+            self.rollback()
+            raise
+
+    def revise(
+        self,
+        code_uid,
+        payload,
+        *,
+        expected_version,
+        actor_uid,
+    ):
+        record = self._get(code_uid, for_update=True)
+        self._assert_expected_version(record, expected_version)
+        if record.status not in {"draft", "rejected"}:
+            raise DeviceSemanticInvalid(
+                "only draft or rejected semantic codes can be revised"
+            )
+        snapshot = _semantic_snapshot(payload, existing=record)
+        changed_at = self.now_factory()
+        next_version = record.current_version + 1
+        revised = _record_from_snapshot(
+            uid=record.uid,
+            snapshot=snapshot,
+            status="draft",
+            current_version=next_version,
+            actor_uid=actor_uid,
+            created_by=record.created_by,
+            created_at=record.created_at,
+            updated_at=changed_at,
+        )
+        version = DeviceSemanticCodeVersionRecord(
+            uid=self.uid_factory(),
+            code_uid=record.uid,
+            version=next_version,
+            snapshot=dict(snapshot),
+            created_by=actor_uid,
+            created_at=changed_at,
+        )
+        try:
+            result = self.repository.revise_code(revised, version)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def submit(self, code_uid, *, expected_version, actor_uid):
+        record = self._get(code_uid, for_update=True)
+        self._assert_expected_version(record, expected_version)
+        if record.status != "draft":
+            raise DeviceSemanticInvalid(
+                "only draft semantic codes can enter in_review"
+            )
+        changed_at = self.now_factory()
+        try:
+            result = self.repository.set_status(
+                record,
+                "in_review",
+                updated_at=changed_at,
+                updated_by=actor_uid,
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def review(self, code_uid, payload, *, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceSemanticInvalid("payload must be an object")
+        decision = str(payload.get("decision") or "").strip()
+        if decision not in {"approve", "reject"}:
+            raise DeviceSemanticInvalid(
+                "decision must be approve or reject"
+            )
+        reason = _optional_text(payload, "reason", maximum=1000) or ""
+        if decision == "reject" and not reason:
+            raise DeviceSemanticInvalid("rejection reason is required")
+        record = self._get(code_uid, for_update=True)
+        self._assert_expected_version(
+            record,
+            payload.get("expected_version"),
+        )
+        if record.status != "in_review":
+            raise DeviceSemanticInvalid(
+                "only in_review semantic codes can be reviewed"
+            )
+        self.review_authorizer(record.ontology_uid, str(actor_uid))
+        reviewed_at = self.now_factory()
+        review = DeviceSemanticCodeReviewRecord(
+            uid=self.uid_factory(),
+            code_uid=record.uid,
+            version=record.current_version,
+            decision=decision,
+            reason=reason,
+            actor_uid=str(actor_uid),
+            created_at=reviewed_at,
+        )
+        status = "published" if decision == "approve" else "rejected"
+        try:
+            result = self.repository.review_code(record, review, status)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def search(self, filters=None, *, page=1, page_size=20):
+        filters = dict(filters or {})
+        if filters.get("ontology_uid"):
+            filters["ontology_uid"] = _uuid(
+                filters["ontology_uid"],
+                "ontology_uid",
+            )
+        if filters.get("code_type") not in (None, "", *CODE_TYPES):
+            raise DeviceSemanticInvalid("code_type is not supported")
+        if filters.get("status") not in (None, "", *CODE_STATUSES):
+            raise DeviceSemanticInvalid("status is not supported")
+        keyword = str(filters.get("keyword") or "").strip()
+        if len(keyword) > 300:
+            raise DeviceSemanticInvalid(
+                "keyword exceeds 300 characters"
+            )
+        filters["keyword"] = keyword
+        try:
+            page_value = int(page)
+            page_size_value = int(page_size)
+        except (TypeError, ValueError) as exc:
+            raise DeviceSemanticInvalid(
+                "page and page_size must be integers"
+            ) from exc
+        if page_value <= 0 or not 1 <= page_size_value <= 100:
+            raise DeviceSemanticInvalid(
+                "page must be positive and page_size must be 1..100"
+            )
+        return self.repository.search(
+            filters,
+            page=page_value,
+            page_size=page_size_value,
+        )
+
+    def get(self, code_uid):
+        return self._get(code_uid)
+
+    def versions(self, code_uid):
+        record = self._get(code_uid)
+        return self.repository.list_versions(record.uid)
+
+    def reviews(self, code_uid):
+        record = self._get(code_uid)
+        return self.repository.list_reviews(record.uid)
+
+
+def _class(uid: str, name: str, description: str):
+    return {
+        "uid": uid,
+        "name": name,
+        "description": description,
+    }
+
+
+def _property(
+    uid: str,
+    name: str,
+    class_uid: str,
+    *,
+    required=False,
+    cardinality="0..1",
+):
+    return {
+        "uid": uid,
+        "name": name,
+        "class_uid": class_uid,
+        "cardinality": cardinality,
+        "required": bool(required),
+    }
+
+
+def _relation(uid: str, name: str, from_uid: str, to_uid: str):
+    return {
+        "uid": uid,
+        "name": name,
+        "from_class_uid": from_uid,
+        "to_class_uid": to_uid,
+    }
+
+
+def build_device_graph(owner_domain_uid: str) -> dict[str, list[dict]]:
+    domain_uid = _uuid(owner_domain_uid, "owner_domain_uid")
+    classes = [
+        _class("device", "设备", "具有稳定平台标识的可治理设备主对象"),
+        _class("component", "部件", "组成设备并可独立维护的部件"),
+        _class("measurement_point", "测点", "观测设备状态的测量位置或信号"),
+        _class("alarm", "告警", "设备或测点产生的异常事件"),
+        _class("maintenance_record", "维护记录", "设备检查、维修与保养活动"),
+        _class("location", "位置", "设备安装或运行的空间位置"),
+        _class("organization", "组织", "设备所属或运维责任组织"),
+        _class("person", "责任人", "承担设备治理或运维责任的人员"),
+        _class("fault", "故障", "经治理确认的故障分类"),
+        _class("cause", "原因", "故障形成的原因分类"),
+        _class("action", "措施", "消除故障或降低影响的处置措施"),
+    ]
+    properties = [
+        _property(
+            "device.platform_uid",
+            "平台设备UID",
+            "device",
+            required=True,
+            cardinality="1",
+        ),
+        _property(
+            "device.source_code",
+            "源系统设备编码",
+            "device",
+            required=True,
+            cardinality="1..*",
+        ),
+        _property(
+            "device.name",
+            "设备名称",
+            "device",
+            required=True,
+            cardinality="1",
+        ),
+        _property("device.status", "设备状态", "device"),
+        _property("device.location", "设备位置", "device"),
+        _property("component.platform_uid", "平台部件UID", "component"),
+        _property(
+            "measurement_point.platform_uid",
+            "平台测点UID",
+            "measurement_point",
+        ),
+        _property("alarm.platform_uid", "平台告警UID", "alarm"),
+        _property(
+            "maintenance_record.platform_uid",
+            "平台维护记录UID",
+            "maintenance_record",
+        ),
+        _property("fault.code", "故障代码", "fault", required=True),
+        _property("cause.code", "原因代码", "cause", required=True),
+        _property("action.code", "措施代码", "action", required=True),
+    ]
+    relations = [
+        _relation("component.part_of", "部件属于设备", "component", "device"),
+        _relation(
+            "measurement_point.monitors",
+            "测点监测设备",
+            "measurement_point",
+            "device",
+        ),
+        _relation("alarm.raised_by", "告警由设备产生", "alarm", "device"),
+        _relation(
+            "maintenance_record.maintains",
+            "维护记录作用于设备",
+            "maintenance_record",
+            "device",
+        ),
+        _relation("device.located_at", "设备位于", "device", "location"),
+        _relation("device.owned_by", "设备归属组织", "device", "organization"),
+        _relation("device.responsible_to", "设备责任人", "device", "person"),
+        _relation("alarm.classified_as", "告警归类为故障", "alarm", "fault"),
+        _relation("fault.caused_by", "故障由原因导致", "fault", "cause"),
+        _relation(
+            "maintenance_record.uses_action",
+            "维护记录采用措施",
+            "maintenance_record",
+            "action",
+        ),
+    ]
+    mappings = [
+        {
+            "property_uid": "device.platform_uid",
+            "data_element_uid": "platform:device_assets.uid",
+            "source": "device_assets.uid",
+        },
+        {
+            "property_uid": "device.source_code",
+            "data_element_uid": "platform:device_asset_source_mappings.source_code",
+            "source": "device_asset_source_mappings.source_code",
+        },
+        {
+            "property_uid": "device.name",
+            "data_element_uid": "platform:device_assets.name",
+            "source": "device_assets.name",
+        },
+        {
+            "property_uid": "fault.code",
+            "data_element_uid": "platform:device_semantic_codes.fault",
+            "source": "device_semantic_codes.canonical_code",
+        },
+        {
+            "property_uid": "cause.code",
+            "data_element_uid": "platform:device_semantic_codes.cause",
+            "source": "device_semantic_codes.canonical_code",
+        },
+        {
+            "property_uid": "action.code",
+            "data_element_uid": "platform:device_semantic_codes.action",
+            "source": "device_semantic_codes.canonical_code",
+        },
+    ]
+    return {
+        "classes": classes,
+        "properties": properties,
+        "relations": relations,
+        "constraints": [
+            {
+                "uid": "device.platform_uid.unique",
+                "name": "平台设备UID唯一",
+                "expression": "unique(device.platform_uid)",
+            },
+            {
+                "uid": "device.source_identity.unique",
+                "name": "来源设备身份唯一",
+                "expression": (
+                    "unique(source_uid,source_entity,asset_type,source_code)"
+                ),
+            },
+        ],
+        "domain_links": [
+            {"domain_uid": domain_uid, "role": "owner"},
+        ],
+        "element_mappings": mappings,
+    }
+
+
+def assess_device_graph(payload: Any) -> DeviceSemanticProfile:
+    graph = dict(payload or {})
+    classes = {
+        str(item.get("uid"))
+        for item in graph.get("classes") or ()
+        if isinstance(item, dict)
+    }
+    relations = {
+        str(item.get("uid"))
+        for item in graph.get("relations") or ()
+        if isinstance(item, dict)
+    }
+    mappings = {
+        str(item.get("property_uid"))
+        for item in graph.get("element_mappings") or ()
+        if isinstance(item, dict)
+    }
+    missing_classes = tuple(
+        uid for uid in REQUIRED_CLASS_UIDS if uid not in classes
+    )
+    missing_relations = tuple(
+        uid for uid in REQUIRED_RELATION_UIDS if uid not in relations
+    )
+    missing_mappings = tuple(
+        uid for uid in REQUIRED_MAPPING_UIDS if uid not in mappings
+    )
+    return DeviceSemanticProfile(
+        ready_to_publish=not (
+            missing_classes or missing_relations or missing_mappings
+        ),
+        class_count=len(classes),
+        relation_count=len(relations),
+        mapping_count=len(mappings),
+        missing_classes=missing_classes,
+        missing_relations=missing_relations,
+        missing_mappings=missing_mappings,
+    )
+
+
+class DeviceSemanticService:
+    def __init__(
+        self,
+        ontology_repository,
+        *,
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self.ontology_repository = ontology_repository
+        self.commit = commit
+        self.rollback = rollback
+
+    def bootstrap(self, payload, actor_uid) -> DeviceSemanticBootstrapResult:
+        if not isinstance(payload, dict):
+            raise DeviceSemanticInvalid("payload must be an object")
+        existing = self.ontology_repository.find_by_code(
+            DEVICE_ONTOLOGY_CODE
+        )
+        if existing is not None:
+            version = self.ontology_repository.latest_version(existing.uid)
+            if version is None:
+                raise DeviceSemanticInvalid(
+                    "device ontology exists without a draft"
+                )
+            return DeviceSemanticBootstrapResult(
+                ontology=existing,
+                version=version,
+                profile=assess_device_graph(
+                    version.graph_document.to_dict()
+                ),
+                created=False,
+            )
+        domain_uid = _uuid(
+            payload.get("owner_domain_uid"),
+            "owner_domain_uid",
+        )
+        try:
+            ontology = self.ontology_repository.create(
+                code=DEVICE_ONTOLOGY_CODE,
+                name="设备语义本体",
+                owner_uid=str(payload.get("owner_uid") or actor_uid),
+                domain_links=[
+                    {"domain_uid": domain_uid, "role": "owner"}
+                ],
+                created_by=actor_uid,
+            )
+            version = self.ontology_repository.save_draft(
+                ontology.uid,
+                build_device_graph(domain_uid),
+                expected_revision=0,
+                actor_uid=actor_uid,
+            )
+            self.commit()
+            return DeviceSemanticBootstrapResult(
+                ontology=self.ontology_repository.get(ontology.uid),
+                version=version,
+                profile=assess_device_graph(
+                    version.graph_document.to_dict()
+                ),
+                created=True,
+            )
+        except Exception:
+            self.rollback()
+            raise
+
+    def profile(self) -> DeviceSemanticBootstrapResult | None:
+        ontology = self.ontology_repository.find_by_code(
+            DEVICE_ONTOLOGY_CODE
+        )
+        if ontology is None:
+            return None
+        version = self.ontology_repository.latest_version(ontology.uid)
+        if version is None:
+            raise DeviceSemanticInvalid(
+                "device ontology exists without a draft"
+            )
+        return DeviceSemanticBootstrapResult(
+            ontology=ontology,
+            version=version,
+            profile=assess_device_graph(version.graph_document.to_dict()),
+            created=False,
+        )
+
+
+class DeviceOntologyPublicationAuthorizer:
+    def __init__(
+        self,
+        ontology_repository,
+        *,
+        responsibility_lookup: Callable[[str, str], dict],
+    ):
+        self.ontology_repository = ontology_repository
+        self.responsibility_lookup = responsibility_lookup
+
+    def __call__(self, version, actor_uid):
+        ontology = self.ontology_repository.get(version.ontology_uid)
+        if ontology is None:
+            raise DeviceSemanticInvalid("ontology was not found")
+        if ontology.code != DEVICE_ONTOLOGY_CODE:
+            return
+        if not assess_device_graph(
+            version.graph_document.to_dict()
+        ).ready_to_publish:
+            raise DeviceSemanticInvalid(
+                "device semantic profile is incomplete"
+            )
+        self.assert_accountable(ontology.uid, actor_uid)
+
+    def assert_accountable(self, ontology_uid, actor_uid):
+        matrix = self.responsibility_lookup(
+            "device_ontology",
+            str(ontology_uid),
+        )
+        accountable = {
+            str(item.get("user_id"))
+            for item in matrix.get("assignments") or ()
+            if item.get("responsibility_role") == "asset_manager"
+            and item.get("raci_role") == "accountable"
+        }
+        if str(actor_uid) not in accountable:
+            raise DeviceSemanticForbidden(
+                "accountable device asset manager approval is required"
+            )
+
+
+__all__ = [
+    "CODE_STATUSES",
+    "CODE_TYPES",
+    "DEVICE_ONTOLOGY_CODE",
+    "DeviceOntologyPublicationAuthorizer",
+    "DeviceSemanticBootstrapResult",
+    "DeviceSemanticCodeRecord",
+    "DeviceSemanticCodeReviewRecord",
+    "DeviceSemanticCodeService",
+    "DeviceSemanticCodeVersionRecord",
+    "DeviceSemanticForbidden",
+    "DeviceSemanticInvalid",
+    "DeviceSemanticProfile",
+    "DeviceSemanticService",
+    "assess_device_graph",
+    "build_device_graph",
+]

+ 20 - 0
app/core/data_research/errors.py

@@ -58,3 +58,23 @@ class DeviceAssetInvalid(DataResearchError):
 class DeviceAssetNotFound(DataResearchError):
     code = "DEVICE_ASSET_NOT_FOUND"
     http_status = 404
+
+
+class DeviceSemanticInvalid(DataResearchError):
+    code = "DEVICE_SEMANTIC_INVALID"
+    http_status = 422
+
+
+class DeviceSemanticForbidden(DataResearchError):
+    code = "DEVICE_SEMANTIC_FORBIDDEN"
+    http_status = 403
+
+
+class DeviceSemanticNotFound(DataResearchError):
+    code = "DEVICE_SEMANTIC_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceSemanticConflict(DataResearchError):
+    code = "DEVICE_SEMANTIC_CONFLICT"
+    http_status = 409

+ 3 - 0
app/core/data_research/ontology/publication.py

@@ -31,11 +31,13 @@ class OntologyPublicationService:
         repository,
         *,
         outbox_enqueue=lambda **_event: None,
+        publication_authorizer=lambda _version, _actor_uid: None,
         commit=lambda: None,
         rollback=lambda: None,
     ):
         self.repository = repository
         self.outbox_enqueue = outbox_enqueue
+        self.publication_authorizer = publication_authorizer
         self.commit = commit
         self.rollback = rollback
         self._published_by_key = {}
@@ -50,6 +52,7 @@ class OntologyPublicationService:
             issues = validate_graph(version.graph_document.to_dict())
             if issues:
                 raise OntologyValidationFailed(issues)
+            self.publication_authorizer(version, actor_uid)
             published = self.repository.mark_published(version_uid)
             self.outbox_enqueue(
                 aggregate_type="ontology",

+ 26 - 0
app/core/data_research/ontology/repository.py

@@ -54,6 +54,22 @@ class MemoryOntologyRepository:
     def get(self, uid):
         return self.ontologies.get(str(uid))
 
+    def list(self):
+        return sorted(
+            self.ontologies.values(),
+            key=lambda item: item.code,
+        )
+
+    def find_by_code(self, code):
+        return next(
+            (
+                item
+                for item in self.ontologies.values()
+                if item.code == str(code)
+            ),
+            None,
+        )
+
     def get_version(self, uid):
         return self.versions.get(str(uid))
 
@@ -160,6 +176,16 @@ class SqlAlchemyOntologyRepository:
 
         return [self._ontology(model) for model in self.session.query(OntologyModel).order_by(OntologyModel.code).all()]
 
+    def find_by_code(self, code):
+        from app.models.data_research import OntologyModel
+
+        model = (
+            self.session.query(OntologyModel)
+            .filter_by(code=str(code))
+            .first()
+        )
+        return self._ontology(model) if model is not None else None
+
     def create(self, *, code, name, owner_uid, domain_links, created_by=None):
         from app.models.data_research import OntologyDomainLinkModel, OntologyModel
 

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

@@ -34,6 +34,8 @@ DATA_ELEMENTS_PUBLISH = "data-elements:publish"
 ONTOLOGIES_EDIT = "ontologies:edit"
 ONTOLOGIES_PUBLISH = "ontologies:publish"
 DEVICE_ASSETS_EDIT = "device-assets:edit"
+DEVICE_SEMANTICS_EDIT = "device-semantics:edit"
+DEVICE_SEMANTICS_REVIEW = "device-semantics:review"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -53,6 +55,7 @@ ROLE_PERMISSIONS = {
             DATA_ELEMENTS_EDIT,
             ONTOLOGIES_EDIT,
             DEVICE_ASSETS_EDIT,
+            DEVICE_SEMANTICS_EDIT,
         }
     ),
     "admin": frozenset(
@@ -84,6 +87,8 @@ ROLE_PERMISSIONS = {
             ONTOLOGIES_EDIT,
             ONTOLOGIES_PUBLISH,
             DEVICE_ASSETS_EDIT,
+            DEVICE_SEMANTICS_EDIT,
+            DEVICE_SEMANTICS_REVIEW,
         }
     ),
 }
@@ -148,6 +153,12 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if method == "GET":
             return (READ_GOVERNANCE,)
         return (DEVICE_ASSETS_EDIT,)
+    if path.startswith("/api/development/v1/device-semantics"):
+        if method == "GET":
+            return (READ_GOVERNANCE,)
+        if path.endswith("/review"):
+            return (DEVICE_SEMANTICS_REVIEW,)
+        return (DEVICE_SEMANTICS_EDIT,)
     if path.startswith("/api/development/v1/ingestion-jobs"):
         if method == "GET":
             return (READ_GOVERNANCE,)

+ 148 - 0
app/models/data_research.py

@@ -366,6 +366,154 @@ class DeviceAssetVersion(db.Model):
     )
 
 
+class DeviceSemanticCode(db.Model):
+    __tablename__ = "device_semantic_codes"
+    __table_args__ = (
+        db.CheckConstraint(
+            "code_type IN ('fault','cause','action')",
+            name="ck_device_semantic_code_type",
+        ),
+        db.CheckConstraint(
+            "status IN ("
+            "'draft','in_review','published','rejected','retired'"
+            ")",
+            name="ck_device_semantic_code_status",
+        ),
+        db.CheckConstraint(
+            "current_version > 0",
+            name="ck_device_semantic_code_current_version",
+        ),
+        db.CheckConstraint(
+            "suggestion_source IN ('manual','rule','ai')",
+            name="ck_device_semantic_suggestion_source",
+        ),
+        db.CheckConstraint(
+            "confidence IS NULL OR (confidence >= 0 AND confidence <= 1)",
+            name="ck_device_semantic_confidence",
+        ),
+        db.UniqueConstraint(
+            "ontology_uid",
+            "code_type",
+            "canonical_code",
+            name="uq_device_semantic_identity",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    ontology_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.ontologies.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    code_type = db.Column(db.String(20), nullable=False)
+    canonical_code = db.Column(db.String(120), nullable=False)
+    canonical_name = db.Column(db.String(300), nullable=False)
+    definition = db.Column(db.Text)
+    status = db.Column(db.String(20), nullable=False, default="draft")
+    current_version = db.Column(db.Integer, nullable=False, default=1)
+    source_mappings = db.Column(JSONB, nullable=False, default=list)
+    evidence_uids = db.Column(JSONB, nullable=False, default=list)
+    suggestion_source = db.Column(
+        db.String(20),
+        nullable=False,
+        default="manual",
+    )
+    confidence = db.Column(db.Float)
+    created_by = db.Column(db.String(100))
+    updated_by = db.Column(db.String(100))
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    updated_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceSemanticCodeVersion(db.Model):
+    __tablename__ = "device_semantic_code_versions"
+    __table_args__ = (
+        db.CheckConstraint(
+            "version > 0",
+            name="ck_device_semantic_code_version_number",
+        ),
+        db.UniqueConstraint(
+            "code_uid",
+            "version",
+            name="uq_device_semantic_code_version",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    code_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_semantic_codes.uid",
+            ondelete="CASCADE",
+        ),
+        nullable=False,
+    )
+    version = db.Column(db.Integer, nullable=False)
+    snapshot = db.Column(JSONB, nullable=False)
+    created_by = db.Column(db.String(100))
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceSemanticCodeReview(db.Model):
+    __tablename__ = "device_semantic_code_reviews"
+    __table_args__ = (
+        db.CheckConstraint(
+            "version > 0",
+            name="ck_device_semantic_review_version",
+        ),
+        db.CheckConstraint(
+            "decision IN ('approve','reject')",
+            name="ck_device_semantic_review_decision",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    code_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_semantic_codes.uid",
+            ondelete="CASCADE",
+        ),
+        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, default="")
+    actor_uid = db.Column(db.String(100), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
 class EvidenceFragment(db.Model):
     __tablename__ = "evidence_fragments"
     __table_args__ = (

+ 311 - 0
deployment/app/api/data_development/routes.py

@@ -114,6 +114,58 @@ def get_device_asset_service():
     )
 
 
+def _device_ontology_authorizer(ontology_repository):
+    from app.core.data_research.device_semantics import (
+        DeviceOntologyPublicationAuthorizer,
+    )
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+    return DeviceOntologyPublicationAuthorizer(
+        ontology_repository,
+        responsibility_lookup=responsibilities.get,
+    )
+
+
+def get_device_semantic_service():
+    from app.core.data_research.device_semantics import DeviceSemanticService
+    from app.core.data_research.ontology.repository import (
+        SqlAlchemyOntologyRepository,
+    )
+
+    return DeviceSemanticService(
+        SqlAlchemyOntologyRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_device_semantic_code_service():
+    from app.core.data_research.device_semantic_repository import (
+        SqlAlchemyDeviceSemanticCodeRepository,
+    )
+    from app.core.data_research.device_semantics import (
+        DeviceSemanticCodeService,
+    )
+    from app.core.data_research.ontology.repository import (
+        SqlAlchemyOntologyRepository,
+    )
+
+    ontology_repository = SqlAlchemyOntologyRepository(db.session)
+    authorizer = _device_ontology_authorizer(ontology_repository)
+    return DeviceSemanticCodeService(
+        SqlAlchemyDeviceSemanticCodeRepository(db.session),
+        review_authorizer=authorizer.assert_accountable,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
 def get_candidate_decision_service():
     from app.core.data_research.candidate_decisions import CandidateDecisionService
     from app.core.data_research.data_elements import DataElementService
@@ -176,9 +228,11 @@ def get_ontology_service():
     from app.core.events.outbox import enqueue_outbox
 
     repository = SqlAlchemyOntologyRepository(db.session)
+    authorizer = _device_ontology_authorizer(repository)
     publication = OntologyPublicationService(
         repository,
         outbox_enqueue=lambda **event: enqueue_outbox(db.session, **event),
+        publication_authorizer=authorizer,
         commit=db.session.commit,
         rollback=db.session.rollback,
     )
@@ -402,6 +456,86 @@ def _device_asset_import_result(result):
     }
 
 
+def _device_semantic_profile(result):
+    if result is None:
+        return {
+            "bootstrapped": False,
+            "ontology": None,
+            "version": None,
+            "profile": {
+                "ready_to_publish": False,
+                "class_count": 0,
+                "relation_count": 0,
+                "mapping_count": 0,
+                "missing_classes": [],
+                "missing_relations": [],
+                "missing_mappings": [],
+            },
+        }
+    profile = result.profile
+    return {
+        "bootstrapped": True,
+        "created": bool(result.created),
+        "ontology": _ontology(result.ontology),
+        "version": _ontology_version(result.version),
+        "profile": {
+            "ready_to_publish": bool(profile.ready_to_publish),
+            "class_count": int(profile.class_count),
+            "relation_count": int(profile.relation_count),
+            "mapping_count": int(profile.mapping_count),
+            "missing_classes": list(profile.missing_classes),
+            "missing_relations": list(profile.missing_relations),
+            "missing_mappings": list(profile.missing_mappings),
+        },
+    }
+
+
+def _device_semantic_code(record):
+    return {
+        "uid": str(record.uid),
+        "ontology_uid": str(record.ontology_uid),
+        "code_type": record.code_type,
+        "canonical_code": record.canonical_code,
+        "canonical_name": record.canonical_name,
+        "definition": record.definition,
+        "status": record.status,
+        "current_version": int(record.current_version),
+        "source_mappings": [
+            dict(item) for item in record.source_mappings
+        ],
+        "evidence_uids": list(record.evidence_uids),
+        "suggestion_source": record.suggestion_source,
+        "confidence": record.confidence,
+        "created_by": record.created_by,
+        "updated_by": record.updated_by,
+        "created_at": _iso(record.created_at),
+        "updated_at": _iso(record.updated_at),
+    }
+
+
+def _device_semantic_version(record):
+    return {
+        "uid": str(record.uid),
+        "code_uid": str(record.code_uid),
+        "version": int(record.version),
+        "snapshot": dict(record.snapshot),
+        "created_by": record.created_by,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_semantic_review(record):
+    return {
+        "uid": str(record.uid),
+        "code_uid": str(record.code_uid),
+        "version": int(record.version),
+        "decision": record.decision,
+        "reason": record.reason,
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
 def _device_asset_page(name, *, default, maximum):
     from app.core.data_research.errors import DeviceAssetInvalid
 
@@ -654,6 +788,183 @@ def list_device_asset_versions(asset_uid):
         return _error(error)
 
 
+@bp.route("/device-semantics/bootstrap", methods=["POST"])
+def bootstrap_device_semantics():
+    try:
+        result = get_device_semantic_service().bootstrap(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return (
+            jsonify(success(_device_semantic_profile(result))),
+            201 if result.created else 200,
+        )
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/profile", methods=["GET"])
+def get_device_semantic_profile():
+    try:
+        result = get_device_semantic_service().profile()
+        return jsonify(success(_device_semantic_profile(result))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/codes", methods=["GET"])
+def list_device_semantic_codes():
+    filters = {
+        name: request.args.get(name)
+        for name in ("ontology_uid", "code_type", "status", "keyword")
+        if request.args.get(name)
+    }
+    try:
+        records, total = get_device_semantic_code_service().search(
+            filters,
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_semantic_code(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(request.args.get("page", 1)),
+                    "page_size": int(request.args.get("page_size", 20)),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/codes", methods=["POST"])
+def create_device_semantic_code():
+    try:
+        record = get_device_semantic_code_service().create(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_semantic_code(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/codes/<code_uid>", methods=["GET"])
+def get_device_semantic_code(code_uid):
+    try:
+        record = get_device_semantic_code_service().get(code_uid)
+        return jsonify(success(_device_semantic_code(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/revisions",
+    methods=["POST"],
+)
+def revise_device_semantic_code(code_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_device_semantic_code_service().revise(
+            code_uid,
+            payload,
+            expected_version=payload.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_semantic_code(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/submit",
+    methods=["POST"],
+)
+def submit_device_semantic_code(code_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_device_semantic_code_service().submit(
+            code_uid,
+            expected_version=payload.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_semantic_code(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/review",
+    methods=["POST"],
+)
+def review_device_semantic_code(code_uid):
+    try:
+        record, review = get_device_semantic_code_service().review(
+            code_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(
+            success(
+                {
+                    "record": _device_semantic_code(record),
+                    "review": _device_semantic_review(review),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/versions",
+    methods=["GET"],
+)
+def list_device_semantic_code_versions(code_uid):
+    try:
+        records = get_device_semantic_code_service().versions(code_uid)
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_semantic_version(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/reviews",
+    methods=["GET"],
+)
+def list_device_semantic_code_reviews(code_uid):
+    try:
+        records = get_device_semantic_code_service().reviews(code_uid)
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_semantic_review(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

+ 262 - 0
deployment/app/core/data_research/device_semantic_repository.py

@@ -0,0 +1,262 @@
+from __future__ import annotations
+
+from sqlalchemy import func, or_
+
+from app.core.data_research.device_semantics import (
+    DeviceSemanticCodeRecord,
+    DeviceSemanticCodeReviewRecord,
+    DeviceSemanticCodeVersionRecord,
+)
+from app.models.data_research import (
+    DeviceSemanticCode,
+    DeviceSemanticCodeReview,
+    DeviceSemanticCodeVersion,
+    OntologyModel,
+)
+
+
+class SqlAlchemyDeviceSemanticCodeRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _record(model):
+        return DeviceSemanticCodeRecord(
+            uid=str(model.uid),
+            ontology_uid=str(model.ontology_uid),
+            code_type=model.code_type,
+            canonical_code=model.canonical_code,
+            canonical_name=model.canonical_name,
+            definition=model.definition,
+            status=model.status,
+            current_version=int(model.current_version),
+            source_mappings=tuple(
+                dict(item) for item in (model.source_mappings or ())
+            ),
+            evidence_uids=tuple(model.evidence_uids or ()),
+            suggestion_source=model.suggestion_source,
+            confidence=model.confidence,
+            created_by=model.created_by,
+            updated_by=model.updated_by,
+            created_at=model.created_at,
+            updated_at=model.updated_at,
+        )
+
+    @staticmethod
+    def _version_record(model):
+        return DeviceSemanticCodeVersionRecord(
+            uid=str(model.uid),
+            code_uid=str(model.code_uid),
+            version=int(model.version),
+            snapshot=dict(model.snapshot or {}),
+            created_by=model.created_by,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _review_record(model):
+        return DeviceSemanticCodeReviewRecord(
+            uid=str(model.uid),
+            code_uid=str(model.code_uid),
+            version=int(model.version),
+            decision=model.decision,
+            reason=model.reason,
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _model(record):
+        return DeviceSemanticCode(
+            uid=record.uid,
+            ontology_uid=record.ontology_uid,
+            code_type=record.code_type,
+            canonical_code=record.canonical_code,
+            canonical_name=record.canonical_name,
+            definition=record.definition,
+            status=record.status,
+            current_version=record.current_version,
+            source_mappings=list(record.source_mappings),
+            evidence_uids=list(record.evidence_uids),
+            suggestion_source=record.suggestion_source,
+            confidence=record.confidence,
+            created_by=record.created_by,
+            updated_by=record.updated_by,
+            created_at=record.created_at,
+            updated_at=record.updated_at,
+        )
+
+    @staticmethod
+    def _version_model(record):
+        return DeviceSemanticCodeVersion(
+            uid=record.uid,
+            code_uid=record.code_uid,
+            version=record.version,
+            snapshot=record.snapshot,
+            created_by=record.created_by,
+            created_at=record.created_at,
+        )
+
+    def ontology_exists(self, ontology_uid):
+        return (
+            self.session.query(OntologyModel.uid)
+            .filter_by(uid=str(ontology_uid))
+            .first()
+            is not None
+        )
+
+    def is_device_ontology(self, ontology_uid):
+        return (
+            self.session.query(OntologyModel.uid)
+            .filter_by(
+                uid=str(ontology_uid),
+                code="DEVICE_SEMANTIC",
+            )
+            .first()
+            is not None
+        )
+
+    def find_code(self, ontology_uid, code_type, canonical_code):
+        model = (
+            self.session.query(DeviceSemanticCode)
+            .filter_by(
+                ontology_uid=str(ontology_uid),
+                code_type=str(code_type),
+                canonical_code=str(canonical_code),
+            )
+            .first()
+        )
+        return self._record(model) if model is not None else None
+
+    def get_code(self, code_uid, *, for_update=False):
+        query = self.session.query(DeviceSemanticCode).filter_by(
+            uid=str(code_uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._record(model) if model is not None else None
+
+    def create_code(self, record, version):
+        model = self._model(record)
+        self.session.add(model)
+        self.session.add(self._version_model(version))
+        self.session.flush()
+        return self._record(model)
+
+    def revise_code(self, record, version):
+        model = self.session.get(DeviceSemanticCode, str(record.uid))
+        for field in (
+            "canonical_name",
+            "definition",
+            "status",
+            "current_version",
+            "source_mappings",
+            "evidence_uids",
+            "suggestion_source",
+            "confidence",
+            "updated_by",
+            "updated_at",
+        ):
+            value = getattr(record, field)
+            if field in {"source_mappings", "evidence_uids"}:
+                value = list(value)
+            setattr(model, field, value)
+        self.session.add(self._version_model(version))
+        self.session.flush()
+        return self._record(model)
+
+    def set_status(self, record, status, *, updated_at, updated_by):
+        model = self.session.get(DeviceSemanticCode, str(record.uid))
+        model.status = status
+        model.updated_at = updated_at
+        model.updated_by = updated_by
+        self.session.flush()
+        return self._record(model)
+
+    def review_code(self, record, review, status):
+        model = self.session.get(DeviceSemanticCode, str(record.uid))
+        review_model = DeviceSemanticCodeReview(
+            uid=review.uid,
+            code_uid=review.code_uid,
+            version=review.version,
+            decision=review.decision,
+            reason=review.reason,
+            actor_uid=review.actor_uid,
+            created_at=review.created_at,
+        )
+        model.status = status
+        model.updated_at = review.created_at
+        model.updated_by = review.actor_uid
+        self.session.add(review_model)
+        self.session.flush()
+        return self._record(model), self._review_record(review_model)
+
+    @staticmethod
+    def _filtered(query, filters):
+        if filters.get("ontology_uid"):
+            query = query.filter(
+                DeviceSemanticCode.ontology_uid
+                == str(filters["ontology_uid"])
+            )
+        if filters.get("code_type"):
+            query = query.filter(
+                DeviceSemanticCode.code_type == str(filters["code_type"])
+            )
+        if filters.get("status"):
+            query = query.filter(
+                DeviceSemanticCode.status == str(filters["status"])
+            )
+        if filters.get("keyword"):
+            pattern = f"%{filters['keyword']}%"
+            query = query.filter(
+                or_(
+                    DeviceSemanticCode.canonical_code.ilike(pattern),
+                    DeviceSemanticCode.canonical_name.ilike(pattern),
+                    DeviceSemanticCode.definition.ilike(pattern),
+                )
+            )
+        return query
+
+    def search(self, filters, *, page, page_size):
+        base = self._filtered(
+            self.session.query(DeviceSemanticCode),
+            filters,
+        )
+        total = base.with_entities(
+            func.count(DeviceSemanticCode.uid)
+        ).scalar() or 0
+        models = (
+            base.order_by(
+                DeviceSemanticCode.code_type.asc(),
+                DeviceSemanticCode.canonical_code.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._record(model) for model in models], int(total)
+
+    def list_versions(self, code_uid):
+        return [
+            self._version_record(model)
+            for model in self.session.query(DeviceSemanticCodeVersion)
+            .filter_by(code_uid=str(code_uid))
+            .order_by(DeviceSemanticCodeVersion.version.desc())
+            .all()
+        ]
+
+    def list_reviews(self, code_uid):
+        return [
+            self._review_record(model)
+            for model in self.session.query(DeviceSemanticCodeReview)
+            .filter_by(code_uid=str(code_uid))
+            .order_by(
+                DeviceSemanticCodeReview.created_at.desc(),
+                DeviceSemanticCodeReview.uid.desc(),
+            )
+            .all()
+        ]
+
+
+__all__ = ["SqlAlchemyDeviceSemanticCodeRepository"]

+ 969 - 0
deployment/app/core/data_research/device_semantics.py

@@ -0,0 +1,969 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+from app.core.data_research.errors import (
+    DeviceSemanticConflict,
+    DeviceSemanticForbidden,
+    DeviceSemanticInvalid,
+    DeviceSemanticNotFound,
+)
+
+DEVICE_ONTOLOGY_CODE = "DEVICE_SEMANTIC"
+REQUIRED_CLASS_UIDS = (
+    "action",
+    "alarm",
+    "cause",
+    "component",
+    "device",
+    "fault",
+    "location",
+    "maintenance_record",
+    "measurement_point",
+    "organization",
+    "person",
+)
+REQUIRED_RELATION_UIDS = (
+    "alarm.classified_as",
+    "alarm.raised_by",
+    "component.part_of",
+    "device.located_at",
+    "device.owned_by",
+    "device.responsible_to",
+    "fault.caused_by",
+    "maintenance_record.maintains",
+    "maintenance_record.uses_action",
+    "measurement_point.monitors",
+)
+REQUIRED_MAPPING_UIDS = (
+    "device.platform_uid",
+    "device.source_code",
+)
+CODE_TYPES = frozenset({"fault", "cause", "action"})
+CODE_STATUSES = frozenset(
+    {"draft", "in_review", "published", "rejected", "retired"}
+)
+SUGGESTION_SOURCES = frozenset({"manual", "rule", "ai"})
+SEMANTIC_SECRET_KEYS = frozenset(
+    {
+        "password",
+        "passwd",
+        "credential",
+        "credentials",
+        "token",
+        "api_key",
+        "authorization",
+        "connection_string",
+        "connection_url",
+        "encrypted_payload",
+        "phone",
+        "mobile",
+        "email",
+        "contact",
+        "contact_phone",
+        "contact_email",
+        "id_number",
+        "personal_address",
+    }
+)
+
+
+@dataclass(frozen=True)
+class DeviceSemanticProfile:
+    ready_to_publish: bool
+    class_count: int
+    relation_count: int
+    mapping_count: int
+    missing_classes: tuple[str, ...]
+    missing_relations: tuple[str, ...]
+    missing_mappings: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class DeviceSemanticBootstrapResult:
+    ontology: Any
+    version: Any
+    profile: DeviceSemanticProfile
+    created: bool
+
+
+@dataclass(frozen=True)
+class DeviceSemanticCodeRecord:
+    uid: str
+    ontology_uid: str
+    code_type: str
+    canonical_code: str
+    canonical_name: str
+    definition: str | None
+    status: str
+    current_version: int
+    source_mappings: tuple[dict[str, str], ...]
+    evidence_uids: tuple[str, ...]
+    suggestion_source: str
+    confidence: float | None
+    created_by: str | None
+    updated_by: str | None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceSemanticCodeVersionRecord:
+    uid: str
+    code_uid: str
+    version: int
+    snapshot: dict[str, Any]
+    created_by: str | None
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceSemanticCodeReviewRecord:
+    uid: str
+    code_uid: str
+    version: int
+    decision: str
+    reason: str
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(UUID(str(value)))
+    except (AttributeError, TypeError, ValueError) as exc:
+        raise DeviceSemanticInvalid(f"{field} must be a UUID") from exc
+
+
+def _required_text(
+    payload: dict[str, Any],
+    field: str,
+    *,
+    maximum: int,
+) -> str:
+    value = str(payload.get(field) or "").strip()
+    if not value:
+        raise DeviceSemanticInvalid(f"{field} is required")
+    if len(value) > maximum:
+        raise DeviceSemanticInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return value
+
+
+def _optional_text(
+    payload: dict[str, Any],
+    field: str,
+    *,
+    maximum: int,
+) -> str | None:
+    value = str(payload.get(field) or "").strip()
+    if not value:
+        return None
+    if len(value) > maximum:
+        raise DeviceSemanticInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return value
+
+
+def _reject_semantic_secrets(value: Any) -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if str(key).strip().casefold() in SEMANTIC_SECRET_KEYS:
+                raise DeviceSemanticInvalid(
+                    "secret values are not allowed in semantic codes"
+                )
+            _reject_semantic_secrets(item)
+    elif isinstance(value, (list, tuple)):
+        for item in value:
+            _reject_semantic_secrets(item)
+
+
+def _source_mappings(value: Any) -> tuple[dict[str, str], ...]:
+    raw_items = value or ()
+    if not isinstance(raw_items, (list, tuple)):
+        raise DeviceSemanticInvalid("source_mappings must be a list")
+    if len(raw_items) > 100:
+        raise DeviceSemanticInvalid(
+            "source_mappings exceed the 100 item boundary"
+        )
+    _reject_semantic_secrets(raw_items)
+    items = []
+    identities = set()
+    for raw in raw_items:
+        if not isinstance(raw, dict):
+            raise DeviceSemanticInvalid(
+                "source mapping must be an object"
+            )
+        item = {
+            "source_uid": _uuid(raw.get("source_uid"), "source_uid"),
+            "source_code": _required_text(
+                raw,
+                "source_code",
+                maximum=300,
+            ),
+        }
+        identity = (item["source_uid"], item["source_code"])
+        if identity in identities:
+            raise DeviceSemanticInvalid("duplicate source mapping")
+        identities.add(identity)
+        items.append(item)
+    return tuple(items)
+
+
+def _evidence(value: Any) -> tuple[str, ...]:
+    raw_items = value or ()
+    if not isinstance(raw_items, (list, tuple)):
+        raise DeviceSemanticInvalid("evidence_uids must be a list")
+    if len(raw_items) > 100:
+        raise DeviceSemanticInvalid(
+            "evidence_uids exceed the 100 item boundary"
+        )
+    items = tuple(str(item).strip() for item in raw_items)
+    if any(not item or len(item) > 120 for item in items):
+        raise DeviceSemanticInvalid("evidence uid is invalid")
+    if len(set(items)) != len(items):
+        raise DeviceSemanticInvalid("duplicate evidence uid")
+    return items
+
+
+def _proposal(payload: dict[str, Any]):
+    suggestion_source = str(
+        payload.get("suggestion_source") or "manual"
+    ).strip()
+    if suggestion_source not in SUGGESTION_SOURCES:
+        raise DeviceSemanticInvalid("suggestion_source is not supported")
+    evidence_uids = _evidence(payload.get("evidence_uids"))
+    if suggestion_source != "manual" and not evidence_uids:
+        raise DeviceSemanticInvalid(
+            "rule and AI suggestions require evidence"
+        )
+    raw_confidence = payload.get("confidence")
+    confidence = None
+    if raw_confidence not in (None, ""):
+        try:
+            confidence = float(raw_confidence)
+        except (TypeError, ValueError) as exc:
+            raise DeviceSemanticInvalid(
+                "confidence must be numeric"
+            ) from exc
+        if confidence < 0 or confidence > 1:
+            raise DeviceSemanticInvalid(
+                "confidence must be between 0 and 1"
+            )
+    if suggestion_source == "ai" and confidence is None:
+        raise DeviceSemanticInvalid(
+            "AI suggestion confidence is required"
+        )
+    return suggestion_source, evidence_uids, confidence
+
+
+def _semantic_snapshot(
+    payload: dict[str, Any],
+    *,
+    existing: DeviceSemanticCodeRecord | None = None,
+):
+    if not isinstance(payload, dict):
+        raise DeviceSemanticInvalid("payload must be an object")
+    _reject_semantic_secrets(payload)
+    if existing is None:
+        ontology_uid = _uuid(
+            payload.get("ontology_uid"),
+            "ontology_uid",
+        )
+        code_type = str(payload.get("code_type") or "").strip()
+        if code_type not in CODE_TYPES:
+            raise DeviceSemanticInvalid("code_type is not supported")
+        canonical_code = _required_text(
+            payload,
+            "canonical_code",
+            maximum=120,
+        ).upper()
+    else:
+        ontology_uid = existing.ontology_uid
+        code_type = existing.code_type
+        canonical_code = existing.canonical_code
+    effective_payload = dict(payload)
+    if existing is not None:
+        effective_payload.setdefault(
+            "canonical_name",
+            existing.canonical_name,
+        )
+        effective_payload.setdefault("definition", existing.definition)
+    suggestion_source, evidence_uids, confidence = _proposal(
+        {
+            "suggestion_source": payload.get(
+                "suggestion_source",
+                existing.suggestion_source if existing else "manual",
+            ),
+            "evidence_uids": payload.get(
+                "evidence_uids",
+                list(existing.evidence_uids) if existing else [],
+            ),
+            "confidence": payload.get(
+                "confidence",
+                existing.confidence if existing else None,
+            ),
+        }
+    )
+    source_mappings = _source_mappings(
+        payload.get(
+            "source_mappings",
+            list(existing.source_mappings) if existing else [],
+        )
+    )
+    snapshot = {
+        "ontology_uid": ontology_uid,
+        "code_type": code_type,
+        "canonical_code": canonical_code,
+        "canonical_name": _required_text(
+            effective_payload,
+            "canonical_name",
+            maximum=300,
+        ),
+        "definition": _optional_text(
+            effective_payload,
+            "definition",
+            maximum=2000,
+        ),
+        "source_mappings": [dict(item) for item in source_mappings],
+        "evidence_uids": list(evidence_uids),
+        "suggestion_source": suggestion_source,
+        "confidence": confidence,
+    }
+    return snapshot
+
+
+def _version(value: Any, field: str = "expected_version") -> int:
+    try:
+        version = int(value)
+    except (TypeError, ValueError) as exc:
+        raise DeviceSemanticInvalid(f"{field} must be an integer") from exc
+    if version < 0:
+        raise DeviceSemanticInvalid(f"{field} must not be negative")
+    return version
+
+
+def _record_from_snapshot(
+    *,
+    uid: str,
+    snapshot: dict[str, Any],
+    status: str,
+    current_version: int,
+    actor_uid: str | None,
+    created_at: datetime,
+    updated_at: datetime,
+    created_by: str | None = None,
+) -> DeviceSemanticCodeRecord:
+    return DeviceSemanticCodeRecord(
+        uid=uid,
+        ontology_uid=snapshot["ontology_uid"],
+        code_type=snapshot["code_type"],
+        canonical_code=snapshot["canonical_code"],
+        canonical_name=snapshot["canonical_name"],
+        definition=snapshot["definition"],
+        status=status,
+        current_version=current_version,
+        source_mappings=tuple(
+            dict(item) for item in snapshot["source_mappings"]
+        ),
+        evidence_uids=tuple(snapshot["evidence_uids"]),
+        suggestion_source=snapshot["suggestion_source"],
+        confidence=snapshot["confidence"],
+        created_by=created_by if created_by is not None else actor_uid,
+        updated_by=actor_uid,
+        created_at=created_at,
+        updated_at=updated_at,
+    )
+
+
+class DeviceSemanticCodeService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        review_authorizer: Callable[[str, str], Any] = (
+            lambda _ontology_uid, _actor_uid: None
+        ),
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.review_authorizer = review_authorizer
+        self.commit = commit
+        self.rollback = rollback
+
+    @staticmethod
+    def _assert_expected_version(record, expected_version):
+        expected = _version(expected_version)
+        if record.current_version != expected:
+            raise DeviceSemanticConflict(
+                "semantic code version has changed"
+            )
+
+    def _get(self, code_uid, *, for_update=False):
+        uid = _uuid(code_uid, "code_uid")
+        record = self.repository.get_code(uid, for_update=for_update)
+        if record is None:
+            raise DeviceSemanticNotFound("semantic code was not found")
+        return record
+
+    def create(self, payload, actor_uid):
+        snapshot = _semantic_snapshot(payload)
+        if not self.repository.ontology_exists(snapshot["ontology_uid"]):
+            raise DeviceSemanticNotFound("ontology was not found")
+        if not self.repository.is_device_ontology(
+            snapshot["ontology_uid"]
+        ):
+            raise DeviceSemanticInvalid(
+                "semantic codes must belong to the device ontology"
+            )
+        existing = self.repository.find_code(
+            snapshot["ontology_uid"],
+            snapshot["code_type"],
+            snapshot["canonical_code"],
+        )
+        if existing is not None:
+            raise DeviceSemanticConflict("semantic code already exists")
+        created_at = self.now_factory()
+        code_uid = self.uid_factory()
+        record = _record_from_snapshot(
+            uid=code_uid,
+            snapshot=snapshot,
+            status="draft",
+            current_version=1,
+            actor_uid=actor_uid,
+            created_at=created_at,
+            updated_at=created_at,
+        )
+        version = DeviceSemanticCodeVersionRecord(
+            uid=self.uid_factory(),
+            code_uid=code_uid,
+            version=1,
+            snapshot=dict(snapshot),
+            created_by=actor_uid,
+            created_at=created_at,
+        )
+        try:
+            created = self.repository.create_code(record, version)
+            self.commit()
+            return created
+        except Exception:
+            self.rollback()
+            raise
+
+    def revise(
+        self,
+        code_uid,
+        payload,
+        *,
+        expected_version,
+        actor_uid,
+    ):
+        record = self._get(code_uid, for_update=True)
+        self._assert_expected_version(record, expected_version)
+        if record.status not in {"draft", "rejected"}:
+            raise DeviceSemanticInvalid(
+                "only draft or rejected semantic codes can be revised"
+            )
+        snapshot = _semantic_snapshot(payload, existing=record)
+        changed_at = self.now_factory()
+        next_version = record.current_version + 1
+        revised = _record_from_snapshot(
+            uid=record.uid,
+            snapshot=snapshot,
+            status="draft",
+            current_version=next_version,
+            actor_uid=actor_uid,
+            created_by=record.created_by,
+            created_at=record.created_at,
+            updated_at=changed_at,
+        )
+        version = DeviceSemanticCodeVersionRecord(
+            uid=self.uid_factory(),
+            code_uid=record.uid,
+            version=next_version,
+            snapshot=dict(snapshot),
+            created_by=actor_uid,
+            created_at=changed_at,
+        )
+        try:
+            result = self.repository.revise_code(revised, version)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def submit(self, code_uid, *, expected_version, actor_uid):
+        record = self._get(code_uid, for_update=True)
+        self._assert_expected_version(record, expected_version)
+        if record.status != "draft":
+            raise DeviceSemanticInvalid(
+                "only draft semantic codes can enter in_review"
+            )
+        changed_at = self.now_factory()
+        try:
+            result = self.repository.set_status(
+                record,
+                "in_review",
+                updated_at=changed_at,
+                updated_by=actor_uid,
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def review(self, code_uid, payload, *, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceSemanticInvalid("payload must be an object")
+        decision = str(payload.get("decision") or "").strip()
+        if decision not in {"approve", "reject"}:
+            raise DeviceSemanticInvalid(
+                "decision must be approve or reject"
+            )
+        reason = _optional_text(payload, "reason", maximum=1000) or ""
+        if decision == "reject" and not reason:
+            raise DeviceSemanticInvalid("rejection reason is required")
+        record = self._get(code_uid, for_update=True)
+        self._assert_expected_version(
+            record,
+            payload.get("expected_version"),
+        )
+        if record.status != "in_review":
+            raise DeviceSemanticInvalid(
+                "only in_review semantic codes can be reviewed"
+            )
+        self.review_authorizer(record.ontology_uid, str(actor_uid))
+        reviewed_at = self.now_factory()
+        review = DeviceSemanticCodeReviewRecord(
+            uid=self.uid_factory(),
+            code_uid=record.uid,
+            version=record.current_version,
+            decision=decision,
+            reason=reason,
+            actor_uid=str(actor_uid),
+            created_at=reviewed_at,
+        )
+        status = "published" if decision == "approve" else "rejected"
+        try:
+            result = self.repository.review_code(record, review, status)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def search(self, filters=None, *, page=1, page_size=20):
+        filters = dict(filters or {})
+        if filters.get("ontology_uid"):
+            filters["ontology_uid"] = _uuid(
+                filters["ontology_uid"],
+                "ontology_uid",
+            )
+        if filters.get("code_type") not in (None, "", *CODE_TYPES):
+            raise DeviceSemanticInvalid("code_type is not supported")
+        if filters.get("status") not in (None, "", *CODE_STATUSES):
+            raise DeviceSemanticInvalid("status is not supported")
+        keyword = str(filters.get("keyword") or "").strip()
+        if len(keyword) > 300:
+            raise DeviceSemanticInvalid(
+                "keyword exceeds 300 characters"
+            )
+        filters["keyword"] = keyword
+        try:
+            page_value = int(page)
+            page_size_value = int(page_size)
+        except (TypeError, ValueError) as exc:
+            raise DeviceSemanticInvalid(
+                "page and page_size must be integers"
+            ) from exc
+        if page_value <= 0 or not 1 <= page_size_value <= 100:
+            raise DeviceSemanticInvalid(
+                "page must be positive and page_size must be 1..100"
+            )
+        return self.repository.search(
+            filters,
+            page=page_value,
+            page_size=page_size_value,
+        )
+
+    def get(self, code_uid):
+        return self._get(code_uid)
+
+    def versions(self, code_uid):
+        record = self._get(code_uid)
+        return self.repository.list_versions(record.uid)
+
+    def reviews(self, code_uid):
+        record = self._get(code_uid)
+        return self.repository.list_reviews(record.uid)
+
+
+def _class(uid: str, name: str, description: str):
+    return {
+        "uid": uid,
+        "name": name,
+        "description": description,
+    }
+
+
+def _property(
+    uid: str,
+    name: str,
+    class_uid: str,
+    *,
+    required=False,
+    cardinality="0..1",
+):
+    return {
+        "uid": uid,
+        "name": name,
+        "class_uid": class_uid,
+        "cardinality": cardinality,
+        "required": bool(required),
+    }
+
+
+def _relation(uid: str, name: str, from_uid: str, to_uid: str):
+    return {
+        "uid": uid,
+        "name": name,
+        "from_class_uid": from_uid,
+        "to_class_uid": to_uid,
+    }
+
+
+def build_device_graph(owner_domain_uid: str) -> dict[str, list[dict]]:
+    domain_uid = _uuid(owner_domain_uid, "owner_domain_uid")
+    classes = [
+        _class("device", "设备", "具有稳定平台标识的可治理设备主对象"),
+        _class("component", "部件", "组成设备并可独立维护的部件"),
+        _class("measurement_point", "测点", "观测设备状态的测量位置或信号"),
+        _class("alarm", "告警", "设备或测点产生的异常事件"),
+        _class("maintenance_record", "维护记录", "设备检查、维修与保养活动"),
+        _class("location", "位置", "设备安装或运行的空间位置"),
+        _class("organization", "组织", "设备所属或运维责任组织"),
+        _class("person", "责任人", "承担设备治理或运维责任的人员"),
+        _class("fault", "故障", "经治理确认的故障分类"),
+        _class("cause", "原因", "故障形成的原因分类"),
+        _class("action", "措施", "消除故障或降低影响的处置措施"),
+    ]
+    properties = [
+        _property(
+            "device.platform_uid",
+            "平台设备UID",
+            "device",
+            required=True,
+            cardinality="1",
+        ),
+        _property(
+            "device.source_code",
+            "源系统设备编码",
+            "device",
+            required=True,
+            cardinality="1..*",
+        ),
+        _property(
+            "device.name",
+            "设备名称",
+            "device",
+            required=True,
+            cardinality="1",
+        ),
+        _property("device.status", "设备状态", "device"),
+        _property("device.location", "设备位置", "device"),
+        _property("component.platform_uid", "平台部件UID", "component"),
+        _property(
+            "measurement_point.platform_uid",
+            "平台测点UID",
+            "measurement_point",
+        ),
+        _property("alarm.platform_uid", "平台告警UID", "alarm"),
+        _property(
+            "maintenance_record.platform_uid",
+            "平台维护记录UID",
+            "maintenance_record",
+        ),
+        _property("fault.code", "故障代码", "fault", required=True),
+        _property("cause.code", "原因代码", "cause", required=True),
+        _property("action.code", "措施代码", "action", required=True),
+    ]
+    relations = [
+        _relation("component.part_of", "部件属于设备", "component", "device"),
+        _relation(
+            "measurement_point.monitors",
+            "测点监测设备",
+            "measurement_point",
+            "device",
+        ),
+        _relation("alarm.raised_by", "告警由设备产生", "alarm", "device"),
+        _relation(
+            "maintenance_record.maintains",
+            "维护记录作用于设备",
+            "maintenance_record",
+            "device",
+        ),
+        _relation("device.located_at", "设备位于", "device", "location"),
+        _relation("device.owned_by", "设备归属组织", "device", "organization"),
+        _relation("device.responsible_to", "设备责任人", "device", "person"),
+        _relation("alarm.classified_as", "告警归类为故障", "alarm", "fault"),
+        _relation("fault.caused_by", "故障由原因导致", "fault", "cause"),
+        _relation(
+            "maintenance_record.uses_action",
+            "维护记录采用措施",
+            "maintenance_record",
+            "action",
+        ),
+    ]
+    mappings = [
+        {
+            "property_uid": "device.platform_uid",
+            "data_element_uid": "platform:device_assets.uid",
+            "source": "device_assets.uid",
+        },
+        {
+            "property_uid": "device.source_code",
+            "data_element_uid": "platform:device_asset_source_mappings.source_code",
+            "source": "device_asset_source_mappings.source_code",
+        },
+        {
+            "property_uid": "device.name",
+            "data_element_uid": "platform:device_assets.name",
+            "source": "device_assets.name",
+        },
+        {
+            "property_uid": "fault.code",
+            "data_element_uid": "platform:device_semantic_codes.fault",
+            "source": "device_semantic_codes.canonical_code",
+        },
+        {
+            "property_uid": "cause.code",
+            "data_element_uid": "platform:device_semantic_codes.cause",
+            "source": "device_semantic_codes.canonical_code",
+        },
+        {
+            "property_uid": "action.code",
+            "data_element_uid": "platform:device_semantic_codes.action",
+            "source": "device_semantic_codes.canonical_code",
+        },
+    ]
+    return {
+        "classes": classes,
+        "properties": properties,
+        "relations": relations,
+        "constraints": [
+            {
+                "uid": "device.platform_uid.unique",
+                "name": "平台设备UID唯一",
+                "expression": "unique(device.platform_uid)",
+            },
+            {
+                "uid": "device.source_identity.unique",
+                "name": "来源设备身份唯一",
+                "expression": (
+                    "unique(source_uid,source_entity,asset_type,source_code)"
+                ),
+            },
+        ],
+        "domain_links": [
+            {"domain_uid": domain_uid, "role": "owner"},
+        ],
+        "element_mappings": mappings,
+    }
+
+
+def assess_device_graph(payload: Any) -> DeviceSemanticProfile:
+    graph = dict(payload or {})
+    classes = {
+        str(item.get("uid"))
+        for item in graph.get("classes") or ()
+        if isinstance(item, dict)
+    }
+    relations = {
+        str(item.get("uid"))
+        for item in graph.get("relations") or ()
+        if isinstance(item, dict)
+    }
+    mappings = {
+        str(item.get("property_uid"))
+        for item in graph.get("element_mappings") or ()
+        if isinstance(item, dict)
+    }
+    missing_classes = tuple(
+        uid for uid in REQUIRED_CLASS_UIDS if uid not in classes
+    )
+    missing_relations = tuple(
+        uid for uid in REQUIRED_RELATION_UIDS if uid not in relations
+    )
+    missing_mappings = tuple(
+        uid for uid in REQUIRED_MAPPING_UIDS if uid not in mappings
+    )
+    return DeviceSemanticProfile(
+        ready_to_publish=not (
+            missing_classes or missing_relations or missing_mappings
+        ),
+        class_count=len(classes),
+        relation_count=len(relations),
+        mapping_count=len(mappings),
+        missing_classes=missing_classes,
+        missing_relations=missing_relations,
+        missing_mappings=missing_mappings,
+    )
+
+
+class DeviceSemanticService:
+    def __init__(
+        self,
+        ontology_repository,
+        *,
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self.ontology_repository = ontology_repository
+        self.commit = commit
+        self.rollback = rollback
+
+    def bootstrap(self, payload, actor_uid) -> DeviceSemanticBootstrapResult:
+        if not isinstance(payload, dict):
+            raise DeviceSemanticInvalid("payload must be an object")
+        existing = self.ontology_repository.find_by_code(
+            DEVICE_ONTOLOGY_CODE
+        )
+        if existing is not None:
+            version = self.ontology_repository.latest_version(existing.uid)
+            if version is None:
+                raise DeviceSemanticInvalid(
+                    "device ontology exists without a draft"
+                )
+            return DeviceSemanticBootstrapResult(
+                ontology=existing,
+                version=version,
+                profile=assess_device_graph(
+                    version.graph_document.to_dict()
+                ),
+                created=False,
+            )
+        domain_uid = _uuid(
+            payload.get("owner_domain_uid"),
+            "owner_domain_uid",
+        )
+        try:
+            ontology = self.ontology_repository.create(
+                code=DEVICE_ONTOLOGY_CODE,
+                name="设备语义本体",
+                owner_uid=str(payload.get("owner_uid") or actor_uid),
+                domain_links=[
+                    {"domain_uid": domain_uid, "role": "owner"}
+                ],
+                created_by=actor_uid,
+            )
+            version = self.ontology_repository.save_draft(
+                ontology.uid,
+                build_device_graph(domain_uid),
+                expected_revision=0,
+                actor_uid=actor_uid,
+            )
+            self.commit()
+            return DeviceSemanticBootstrapResult(
+                ontology=self.ontology_repository.get(ontology.uid),
+                version=version,
+                profile=assess_device_graph(
+                    version.graph_document.to_dict()
+                ),
+                created=True,
+            )
+        except Exception:
+            self.rollback()
+            raise
+
+    def profile(self) -> DeviceSemanticBootstrapResult | None:
+        ontology = self.ontology_repository.find_by_code(
+            DEVICE_ONTOLOGY_CODE
+        )
+        if ontology is None:
+            return None
+        version = self.ontology_repository.latest_version(ontology.uid)
+        if version is None:
+            raise DeviceSemanticInvalid(
+                "device ontology exists without a draft"
+            )
+        return DeviceSemanticBootstrapResult(
+            ontology=ontology,
+            version=version,
+            profile=assess_device_graph(version.graph_document.to_dict()),
+            created=False,
+        )
+
+
+class DeviceOntologyPublicationAuthorizer:
+    def __init__(
+        self,
+        ontology_repository,
+        *,
+        responsibility_lookup: Callable[[str, str], dict],
+    ):
+        self.ontology_repository = ontology_repository
+        self.responsibility_lookup = responsibility_lookup
+
+    def __call__(self, version, actor_uid):
+        ontology = self.ontology_repository.get(version.ontology_uid)
+        if ontology is None:
+            raise DeviceSemanticInvalid("ontology was not found")
+        if ontology.code != DEVICE_ONTOLOGY_CODE:
+            return
+        if not assess_device_graph(
+            version.graph_document.to_dict()
+        ).ready_to_publish:
+            raise DeviceSemanticInvalid(
+                "device semantic profile is incomplete"
+            )
+        self.assert_accountable(ontology.uid, actor_uid)
+
+    def assert_accountable(self, ontology_uid, actor_uid):
+        matrix = self.responsibility_lookup(
+            "device_ontology",
+            str(ontology_uid),
+        )
+        accountable = {
+            str(item.get("user_id"))
+            for item in matrix.get("assignments") or ()
+            if item.get("responsibility_role") == "asset_manager"
+            and item.get("raci_role") == "accountable"
+        }
+        if str(actor_uid) not in accountable:
+            raise DeviceSemanticForbidden(
+                "accountable device asset manager approval is required"
+            )
+
+
+__all__ = [
+    "CODE_STATUSES",
+    "CODE_TYPES",
+    "DEVICE_ONTOLOGY_CODE",
+    "DeviceOntologyPublicationAuthorizer",
+    "DeviceSemanticBootstrapResult",
+    "DeviceSemanticCodeRecord",
+    "DeviceSemanticCodeReviewRecord",
+    "DeviceSemanticCodeService",
+    "DeviceSemanticCodeVersionRecord",
+    "DeviceSemanticForbidden",
+    "DeviceSemanticInvalid",
+    "DeviceSemanticProfile",
+    "DeviceSemanticService",
+    "assess_device_graph",
+    "build_device_graph",
+]

+ 20 - 0
deployment/app/core/data_research/errors.py

@@ -58,3 +58,23 @@ class DeviceAssetInvalid(DataResearchError):
 class DeviceAssetNotFound(DataResearchError):
     code = "DEVICE_ASSET_NOT_FOUND"
     http_status = 404
+
+
+class DeviceSemanticInvalid(DataResearchError):
+    code = "DEVICE_SEMANTIC_INVALID"
+    http_status = 422
+
+
+class DeviceSemanticForbidden(DataResearchError):
+    code = "DEVICE_SEMANTIC_FORBIDDEN"
+    http_status = 403
+
+
+class DeviceSemanticNotFound(DataResearchError):
+    code = "DEVICE_SEMANTIC_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceSemanticConflict(DataResearchError):
+    code = "DEVICE_SEMANTIC_CONFLICT"
+    http_status = 409

+ 3 - 0
deployment/app/core/data_research/ontology/publication.py

@@ -31,11 +31,13 @@ class OntologyPublicationService:
         repository,
         *,
         outbox_enqueue=lambda **_event: None,
+        publication_authorizer=lambda _version, _actor_uid: None,
         commit=lambda: None,
         rollback=lambda: None,
     ):
         self.repository = repository
         self.outbox_enqueue = outbox_enqueue
+        self.publication_authorizer = publication_authorizer
         self.commit = commit
         self.rollback = rollback
         self._published_by_key = {}
@@ -50,6 +52,7 @@ class OntologyPublicationService:
             issues = validate_graph(version.graph_document.to_dict())
             if issues:
                 raise OntologyValidationFailed(issues)
+            self.publication_authorizer(version, actor_uid)
             published = self.repository.mark_published(version_uid)
             self.outbox_enqueue(
                 aggregate_type="ontology",

+ 26 - 0
deployment/app/core/data_research/ontology/repository.py

@@ -54,6 +54,22 @@ class MemoryOntologyRepository:
     def get(self, uid):
         return self.ontologies.get(str(uid))
 
+    def list(self):
+        return sorted(
+            self.ontologies.values(),
+            key=lambda item: item.code,
+        )
+
+    def find_by_code(self, code):
+        return next(
+            (
+                item
+                for item in self.ontologies.values()
+                if item.code == str(code)
+            ),
+            None,
+        )
+
     def get_version(self, uid):
         return self.versions.get(str(uid))
 
@@ -160,6 +176,16 @@ class SqlAlchemyOntologyRepository:
 
         return [self._ontology(model) for model in self.session.query(OntologyModel).order_by(OntologyModel.code).all()]
 
+    def find_by_code(self, code):
+        from app.models.data_research import OntologyModel
+
+        model = (
+            self.session.query(OntologyModel)
+            .filter_by(code=str(code))
+            .first()
+        )
+        return self._ontology(model) if model is not None else None
+
     def create(self, *, code, name, owner_uid, domain_links, created_by=None):
         from app.models.data_research import OntologyDomainLinkModel, OntologyModel
 

+ 27 - 12
deployment/app/core/system/permissions.py

@@ -1,13 +1,12 @@
 from __future__ import annotations
 
+from collections.abc import Iterable
 from functools import wraps
-from typing import Iterable
 
 from flask import current_app, g, jsonify, request
 
 from app.models.result import failed
 
-
 READ_GOVERNANCE = "governance:read"
 EDIT_GOVERNANCE = "governance:edit"
 APPROVE_REVIEW = "review:approve"
@@ -15,6 +14,8 @@ MANAGE_USERS = "users:manage"
 ACTIVATE_WORKFLOW = "workflow:activate"
 OPERATE_ORDERS = "orders:operate"
 DATASOURCE_POOL_MANAGE = "datasources:pools:manage"
+RESPONSIBILITIES_READ = "governance:responsibilities:read"
+RESPONSIBILITIES_MANAGE = "governance:responsibilities:manage"
 KNOWLEDGE_MANAGE = "knowledge:manage"
 RULES_READ = "rules:read"
 RULES_EDIT = "rules:edit"
@@ -33,9 +34,13 @@ DATA_ELEMENTS_PUBLISH = "data-elements:publish"
 ONTOLOGIES_EDIT = "ontologies:edit"
 ONTOLOGIES_PUBLISH = "ontologies:publish"
 DEVICE_ASSETS_EDIT = "device-assets:edit"
+DEVICE_SEMANTICS_EDIT = "device-semantics:edit"
+DEVICE_SEMANTICS_REVIEW = "device-semantics:review"
 
 ROLE_PERMISSIONS = {
-    "viewer": frozenset({READ_GOVERNANCE, RULES_READ}),
+    "viewer": frozenset(
+        {READ_GOVERNANCE, RULES_READ, RESPONSIBILITIES_READ}
+    ),
     "editor": frozenset(
         {
             READ_GOVERNANCE,
@@ -45,10 +50,12 @@ ROLE_PERMISSIONS = {
             RULES_READ,
             RULES_EDIT,
             RULES_EXECUTE,
+            RESPONSIBILITIES_READ,
             INGESTION_RUN,
             DATA_ELEMENTS_EDIT,
             ONTOLOGIES_EDIT,
             DEVICE_ASSETS_EDIT,
+            DEVICE_SEMANTICS_EDIT,
         }
     ),
     "admin": frozenset(
@@ -60,6 +67,8 @@ ROLE_PERMISSIONS = {
             ACTIVATE_WORKFLOW,
             OPERATE_ORDERS,
             DATASOURCE_POOL_MANAGE,
+            RESPONSIBILITIES_READ,
+            RESPONSIBILITIES_MANAGE,
             KNOWLEDGE_MANAGE,
             RULES_READ,
             RULES_EDIT,
@@ -78,6 +87,8 @@ ROLE_PERMISSIONS = {
             ONTOLOGIES_EDIT,
             ONTOLOGIES_PUBLISH,
             DEVICE_ASSETS_EDIT,
+            DEVICE_SEMANTICS_EDIT,
+            DEVICE_SEMANTICS_REVIEW,
         }
     ),
 }
@@ -90,6 +101,10 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
     method = method.upper()
     if path in {"/api/system/health", "/api/system/auth/login"}:
         return (PUBLIC,)
+    if path.startswith("/api/system/responsibilities/"):
+        if method == "GET":
+            return (RESPONSIBILITIES_READ,)
+        return (RESPONSIBILITIES_MANAGE,)
     if path in {"/api/knowledge/search", "/api/knowledge/ask"}:
         return (READ_GOVERNANCE,)
     if path.startswith("/api/rules"):
@@ -138,6 +153,12 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if method == "GET":
             return (READ_GOVERNANCE,)
         return (DEVICE_ASSETS_EDIT,)
+    if path.startswith("/api/development/v1/device-semantics"):
+        if method == "GET":
+            return (READ_GOVERNANCE,)
+        if path.endswith("/review"):
+            return (DEVICE_SEMANTICS_REVIEW,)
+        return (DEVICE_SEMANTICS_EDIT,)
     if path.startswith("/api/development/v1/ingestion-jobs"):
         if method == "GET":
             return (READ_GOVERNANCE,)
@@ -174,15 +195,9 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         return (EDIT_GOVERNANCE,)
     if path.startswith("/api/system/workbench"):
         return (READ_GOVERNANCE,)
-    if (
-        path == "/api/datasource/pools"
-        or (
-            path.startswith("/api/datasource/")
-            and (
-                path.endswith("/pool")
-                or path.endswith("/pool/invalidate")
-            )
-        )
+    if path == "/api/datasource/pools" or (
+        path.startswith("/api/datasource/")
+        and (path.endswith("/pool") or path.endswith("/pool/invalidate"))
     ):
         return (DATASOURCE_POOL_MANAGE,)
     if path == "/api/datasource/list":

+ 148 - 0
deployment/app/models/data_research.py

@@ -366,6 +366,154 @@ class DeviceAssetVersion(db.Model):
     )
 
 
+class DeviceSemanticCode(db.Model):
+    __tablename__ = "device_semantic_codes"
+    __table_args__ = (
+        db.CheckConstraint(
+            "code_type IN ('fault','cause','action')",
+            name="ck_device_semantic_code_type",
+        ),
+        db.CheckConstraint(
+            "status IN ("
+            "'draft','in_review','published','rejected','retired'"
+            ")",
+            name="ck_device_semantic_code_status",
+        ),
+        db.CheckConstraint(
+            "current_version > 0",
+            name="ck_device_semantic_code_current_version",
+        ),
+        db.CheckConstraint(
+            "suggestion_source IN ('manual','rule','ai')",
+            name="ck_device_semantic_suggestion_source",
+        ),
+        db.CheckConstraint(
+            "confidence IS NULL OR (confidence >= 0 AND confidence <= 1)",
+            name="ck_device_semantic_confidence",
+        ),
+        db.UniqueConstraint(
+            "ontology_uid",
+            "code_type",
+            "canonical_code",
+            name="uq_device_semantic_identity",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    ontology_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.ontologies.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    code_type = db.Column(db.String(20), nullable=False)
+    canonical_code = db.Column(db.String(120), nullable=False)
+    canonical_name = db.Column(db.String(300), nullable=False)
+    definition = db.Column(db.Text)
+    status = db.Column(db.String(20), nullable=False, default="draft")
+    current_version = db.Column(db.Integer, nullable=False, default=1)
+    source_mappings = db.Column(JSONB, nullable=False, default=list)
+    evidence_uids = db.Column(JSONB, nullable=False, default=list)
+    suggestion_source = db.Column(
+        db.String(20),
+        nullable=False,
+        default="manual",
+    )
+    confidence = db.Column(db.Float)
+    created_by = db.Column(db.String(100))
+    updated_by = db.Column(db.String(100))
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    updated_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceSemanticCodeVersion(db.Model):
+    __tablename__ = "device_semantic_code_versions"
+    __table_args__ = (
+        db.CheckConstraint(
+            "version > 0",
+            name="ck_device_semantic_code_version_number",
+        ),
+        db.UniqueConstraint(
+            "code_uid",
+            "version",
+            name="uq_device_semantic_code_version",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    code_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_semantic_codes.uid",
+            ondelete="CASCADE",
+        ),
+        nullable=False,
+    )
+    version = db.Column(db.Integer, nullable=False)
+    snapshot = db.Column(JSONB, nullable=False)
+    created_by = db.Column(db.String(100))
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceSemanticCodeReview(db.Model):
+    __tablename__ = "device_semantic_code_reviews"
+    __table_args__ = (
+        db.CheckConstraint(
+            "version > 0",
+            name="ck_device_semantic_review_version",
+        ),
+        db.CheckConstraint(
+            "decision IN ('approve','reject')",
+            name="ck_device_semantic_review_decision",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    code_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_semantic_codes.uid",
+            ondelete="CASCADE",
+        ),
+        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, default="")
+    actor_uid = db.Column(db.String(100), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
 class EvidenceFragment(db.Model):
     __tablename__ = "evidence_fragments"
     __table_args__ = (

+ 1 - 0
docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md

@@ -157,6 +157,7 @@ P2 不阻塞第一阶段验收。没有完成的 P2 功能必须保留接口和
 | WP-02 | 工程完成,待企业配置 | 本地三级 RBAC;设备责任矩阵;唯一最终设备资产管理员;修订、并发冲突和审计快照;OpenAPI 144 项 | 需要企业提供实际设备资产管理员、治理人员和查看者名单后配置并完成 UAT |
 | WP-03 | 工程完成,待企业接入 | 数据源自动登记;只读目录执行;幂等与主动重采;尝试次数、失败阶段和脱敏诊断;每次执行不可变目录快照;字段级来源证据;本地隔离 PostgreSQL/MySQL 双源实测;OpenAPI 147 项 | 需要企业设备台账库、维修库只读账号、网络、采集范围和数据字典;当前同步执行满足示范版,异步 Worker 与断点续跑保留为后续增强 |
 | WP-04 | 工程完成,待企业数据验收 | 五类设备对象规范化导入;稳定平台 UID 与来源身份唯一映射;无变化不增版、变化生成不可变版本;关键词和类型/状态/来源筛选;来源与责任信息详情;查看者只读、编辑者受控导入;OpenAPI 151 项;本地 PostgreSQL 与页面链路定向验证 | 需要企业提供设备台账与维修样本、字段映射和业务确认;跨来源自动匹配、合并与回滚归 WP-06,质量与血缘汇聚不在 WP-04 |
+| WP-05 | 工程完成,待企业语义与代码集验收 | 通用本体能力已纳入当前分支;11 类设备核心概念、10 条标准关系和平台设备身份映射;设备覆盖度及负责人发布门禁;故障/原因/措施代码不可变版本、证据、提交、审批与审计;查看者、编辑者、审批者权限分离;OpenAPI 161 项;本地 PostgreSQL 和页面链路定向验证 | 需要企业确认设备语义、故障/原因/措施代码和唯一设备资产负责人;规则/AI 建议不会自动发布;受治理 AI 提供方与企业证据集未验收,SEM-21 保持部分建设;跨来源实体匹配归 WP-06 |
 
 ## 7. 12 周执行计划
 

+ 13 - 13
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -554,20 +554,20 @@ WP-04 已补齐设备垂直切片的工程拼图:设备、部件、测点、
 | SEM-05 | 业务语义 / 中英文语义 | 中文首期、英文二期、双语名称和别名检索 | 规划中 |
 | SEM-06 | 业务语义 / 代码集 | 代码、枚举、状态和参考数据的统一管理 | 规划中 |
 | SEM-07 | 业务语义 / 指标口径 | 指标定义、维度、计算口径、责任人和关联报表 | 规划中 |
-| SEM-08 | 本体治理 / 本体中心 | 本体列表、业务域关系、状态和负责人 | 隔离分支完成,待合入 |
-| SEM-09 | 本体治理 / 本体工作台 | 类、属性、关系、约束和映射的图形/结构化编辑 | 隔离分支完成,待合入 |
-| SEM-10 | 本体治理 / 版本发布 | 草稿、ETag、校验、版本、差异、发布和回滚 | 隔离分支完成,待合入 |
-| SEM-11 | 本体治理 / 动态建议 | AI 生成概念、关系、约束和映射建议 | 隔离分支完成,待合入 |
-| SEM-12 | 本体治理 / 建议评审 | 接受、拒绝、修改后接受和证据追溯 | 隔离分支完成,待合入 |
-| SEM-13 | 本体治理 / 跨域本体 | 一个本体服务多个业务域及 owner/contributor/consumer 关系 | 隔离分支完成,待合入 |
-| SEM-14 | 本体治理 / 交换格式 | JSON、OWL 导入导出和后续 RDF 支持 | 隔离分支完成,待合入 |
+| 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-15 | 实体治理 / 实体解析 | 跨系统实体匹配、候选、置信度和解释 | 规划中 |
 | SEM-16 | 实体治理 / 自动合并 | 高置信度自动映射/合并,中低置信度人工审核 | 规划中 |
 | SEM-17 | 实体治理 / 合并回滚 | 合并前快照、撤销、冲突和下游影响 | 规划中 |
-| SEM-18 | 设备语义 / 设备主数据 | 设备、部件、位置、组织和责任人的统一模型 | 规划中 |
-| SEM-19 | 设备语义 / 设备标识 | 建立平台设备 UID 并映射各源系统编码 | 规划中 |
-| SEM-20 | 设备语义 / 故障分类 | 整合各系统故障、原因和措施代码 | 规划中 |
-| SEM-21 | 设备语义 / AI 辅助代码集 | AI 聚类和映射建议,设备资产管理员最终审批 | 规划中 |
+| SEM-18 | 设备语义 / 设备主数据 | 设备、部件、位置、组织和责任人的统一模型 | 工程完成,待企业语义验收 |
+| SEM-19 | 设备语义 / 设备标识 | 建立平台设备 UID 并映射各源系统编码 | 工程完成,待企业数据验收 |
+| SEM-20 | 设备语义 / 故障分类 | 整合各系统故障、原因和措施代码 | 工程完成,待企业代码集验收 |
+| SEM-21 | 设备语义 / AI 辅助代码集 | AI 聚类和映射建议,设备资产管理员最终审批 | 部分建设;证据、置信度和审批链已形成,受治理模型与企业证据集待验收 |
 | SEM-22 | 语义服务 / 知识同步 | 已发布术语、标准和本体同步治理知识库 | 部分建设 |
 
 ### 12.6 数据质量与可观测性
@@ -784,8 +784,8 @@ WP-04 已补齐设备垂直切片的工程拼图:设备、部件、测点、
 | 6 | CAT-01、CAT-04、CAT-13 | 设备资产目录 | 工程能力已形成;展示设备、部件、测点、告警、维修记录、责任信息及来源,待企业数据验收 |
 | 7 | CAT-14、SEM-19 | 平台设备 UID | 单一来源身份的稳定 UID 和源编码映射已形成;跨系统同实体合并顺延至 WP-06 |
 | 8 | SEM-15~17 | 设备实体匹配 | 规则 + AI 生成候选;高置信度自动合并,其他进入审核并可回滚 |
-| 9 | SEM-08~13 | 设备本体工作台 | 从隔离分支合入必要本体能力,建模设备、部件、位置、组织和责任人 |
-| 10 | SEM-20、SEM-21 | 故障代码统一 | 汇总多系统故障/原因/措施代码,AI 辅助聚类,设备资产管理员审批 |
+| 9 | SEM-08~13、SEM-18 | 设备本体工作台 | 通用本体能力及设备标准语义模板已形成;企业责任人仍需确认并完成真实语义发布验收 |
+| 10 | SEM-20、SEM-21 | 故障代码统一 | 故障/原因/措施代码的版本、证据、提交和负责人审批链已形成;企业代码与受治理 AI 建议仍待验收 |
 | 11 | CAT-16、OBS-10 | 设备根因关系图 | 建立设备—部件—告警—故障—维修—停机影响关系 |
 | 12 | CAT-11、KAI-03 | 设备资产搜索 | 按设备名称、源 ID、位置、部件、故障和责任人检索 |
 | 13 | KAI-04、KAI-22 | 设备知识问答 | 基于本体、维修和故障证据回答关系与根因问题并返回来源 |

+ 5 - 0
docs/architecture/DATA_MODEL.md

@@ -149,6 +149,9 @@ flowchart LR
 | `device_assets` | `uid`, `asset_type`, `name`, `current_version`, `content_hash`, `location`, `organization`, `responsible_person`, `attributes` | 设备、部件、测点、告警和维护记录的稳定平台档案 |
 | `device_asset_source_mappings` | `asset_uid`, `source_uid`, `source_entity`, `asset_type`, `source_code`, `source_updated_at` | 源系统身份映射;同一来源身份唯一,不在 WP-04 自动跨源合并 |
 | `device_asset_versions` | `asset_uid`, `version`, `content_hash`, `snapshot`, `source_mapping_uid`, `actor_uid` | 设备资产不可变版本和变更来源追溯 |
+| `device_semantic_codes` | `ontology_uid`, `code_type`, `canonical_code`, `canonical_name`, `status`, `current_version`, `source_mappings`, `evidence_uids`, `suggestion_source`, `confidence` | 故障、原因、措施规范代码;按本体、类型、代码保持唯一身份 |
+| `device_semantic_code_versions` | `code_uid`, `version`, `snapshot`, `created_by` | 设备语义代码不可变版本;修订只追加、不覆盖历史 |
+| `device_semantic_code_reviews` | `code_uid`, `version`, `decision`, `reason`, `actor_uid` | 设备资产负责人对代码版本的批准或退回审计 |
 | `ontologies` | `code`, `owner_uid`, `draft_revision`, `active_version_uid` | 本体稳定身份和生效版本 |
 | `ontology_versions` | `ontology_uid`, `version`, `parent_version_uid`, `graph_document`, `content_hash` | 不可变本体版本 |
 | `ontology_domain_links` | `ontology_uid`, `domain_uid`, `role` | 多业务域 owner/contributor/consumer 关系 |
@@ -177,4 +180,6 @@ flowchart LR
 - n8n 是 Workflow 定义与执行记录的源真相;平台保存治理映射和生效状态。
 - 本体与数据元素的发布版本以 PostgreSQL 为源真相;Neo4j 是可重建的已发布语义投影。
 - 设备资产、源编码映射和不可变版本以 PostgreSQL 为源真相;跨来源匹配、合并与回滚在 WP-06 经审核后实施。
+- 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
+- `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 261 - 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: 151
+x-route-count: 161
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -1839,6 +1839,266 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-semantics/bootstrap":
+    post:
+      tags: [data_development]
+      operationId: data_development_bootstrap_device_semantics_post
+      summary: "bootstrap device semantics"
+      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/device-semantics/codes":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_semantic_codes_get
+      summary: "list device semantic codes"
+      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_device_semantic_code_post
+      summary: "create device semantic code"
+      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/device-semantics/codes/{code_uid}":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_device_semantic_code_get
+      summary: "get device semantic code"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: code_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/device-semantics/codes/{code_uid}/review":
+    post:
+      tags: [data_development]
+      operationId: data_development_review_device_semantic_code_post
+      summary: "review device semantic code"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: code_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/device-semantics/codes/{code_uid}/reviews":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_semantic_code_reviews_get
+      summary: "list device semantic code reviews"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: code_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/device-semantics/codes/{code_uid}/revisions":
+    post:
+      tags: [data_development]
+      operationId: data_development_revise_device_semantic_code_post
+      summary: "revise device semantic code"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: code_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/device-semantics/codes/{code_uid}/submit":
+    post:
+      tags: [data_development]
+      operationId: data_development_submit_device_semantic_code_post
+      summary: "submit device semantic code"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: code_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/device-semantics/codes/{code_uid}/versions":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_semantic_code_versions_get
+      summary: "list device semantic code versions"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: code_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/device-semantics/profile":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_device_semantic_profile_get
+      summary: "get device semantic profile"
+      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/evidence/{evidence_uid}":
     get:
       tags: [data_development]

+ 208 - 0
docs/superpowers/plans/2026-07-29-wp05-device-semantics-ontology.md

@@ -0,0 +1,208 @@
+# WP-05 Device Semantics and Ontology Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build and govern a publishable device semantic model plus versioned fault, cause, and action codes, while reusing the existing ontology workbench, responsibility matrix, versioning, review, publication, rollback, Outbox, and Neo4j projection chain.
+
+**Architecture:** PostgreSQL remains authoritative for the device ontology, code identities, immutable code versions, review records, and responsibility assignments. The canonical device graph is a bounded profile layered on the existing generic `GraphDocument`; a device ontology publication must pass both generic graph validation and device-profile coverage, and the actor must be the accountable device asset manager. Neo4j remains a publication projection rather than the write source. AI- or rule-generated code proposals may enter the same reviewed code lifecycle only with confidence and evidence; WP-05 does not implement cross-source entity merging.
+
+**Tech Stack:** Flask, SQLAlchemy, PostgreSQL JSONB, Alembic, Vue 2, Vuetify, pytest.
+
+## Global Constraints
+
+- Reuse the existing ontology list, structured workbench, ETag draft save, validation, version, diff, publication, rollback, dynamic suggestion review, JSON/OWL exchange, Outbox, and Neo4j projection.
+- The canonical device profile contains device, component, measurement point, alarm, maintenance record, location, organization, person, fault, cause, and action concepts.
+- The device profile maps the stable WP-04 platform UID and source-code identity into the semantic graph; entity matching or merging across sources remains WP-06.
+- Governed code types are `fault`, `cause`, and `action`.
+- A code identity is unique by `(ontology_uid, code_type, canonical_code)`.
+- Code content is immutable by version. Revisions append a version; they never rewrite an earlier snapshot.
+- Code lifecycle is `draft -> in_review -> published|rejected`; a rejected code may be revised and resubmitted.
+- Only the accountable `asset_manager` for the `device_ontology` responsibility scope may publish the device ontology or approve/reject device codes.
+- Rule/AI proposals must contain evidence UIDs; an AI proposal must also contain a confidence in `[0, 1]`. No model-generated proposal is auto-published.
+- Credentials, connection strings, tokens, raw operating measurements, and personal contact fields are rejected from semantic code payloads and never returned.
+- Viewer reads; editor creates, revises, and submits; admin has the review permission but still must pass the accountable asset-manager runtime gate.
+- Real enterprise device, fault, cause, and action codes remain an external acceptance gate.
+- Validation follows the user-approved rule: run only WP-05 domain, persistence, API, permission, frontend, migration, contract, and local browser checks; do not run the full repository regression.
+- Continue on `codex/dataops-phase1-equipment-governance`; do not push or deploy remotely.
+
+---
+
+### Task 1: Canonical Device Ontology Profile and Publication Gate
+
+**Files:**
+- Create: `app/core/data_research/device_semantics.py`
+- Modify: `app/core/data_research/errors.py`
+- Modify: `app/core/data_research/ontology/publication.py`
+- Modify: `app/core/data_research/ontology/repository.py`
+- Test: `tests/data_research/test_device_semantics.py`
+- Modify: `tests/data_research/test_ontology_publication.py`
+
+**Interfaces:**
+- Produces: `build_device_graph(owner_domain_uid)`, `assess_device_graph(graph)`, `DeviceSemanticProfile`, `DeviceOntologyPublicationAuthorizer`, and `OntologyRepository.find_by_code(code)`.
+- Extends: `OntologyPublicationService(..., publication_authorizer=...)`.
+
+- [x] **Step 1: Write failing profile and publication tests**
+
+Cover the complete concept/relation/property/mapping blueprint, deterministic output, missing-profile detection, idempotent bootstrap, generic ontology publication remaining unchanged, device ontology publication rejected when incomplete, and publication rejected when the actor is not the accountable asset manager.
+
+- [x] **Step 2: Verify RED**
+
+Run:
+
+```bash
+PYTHONPATH=. .venv/bin/pytest -q \
+  tests/data_research/test_device_semantics.py \
+  tests/data_research/test_ontology_publication.py
+```
+
+Expected: failure because the device semantic profile and publication authorizer do not exist.
+
+- [x] **Step 3: Implement minimal profile and gate**
+
+Build a deterministic graph with stable semantic UIDs, generic-validator-compatible mappings, device-specific coverage results, and an optional publication callback. Keep the default callback permissive so non-device ontologies and existing unit tests retain their behavior.
+
+- [x] **Step 4: Verify GREEN**
+
+Run the Task 1 test command and expect all tests to pass.
+
+### Task 2: Versioned Fault, Cause, and Action Codes
+
+**Files:**
+- Modify: `app/core/data_research/device_semantics.py`
+- Create: `app/core/data_research/device_semantic_repository.py`
+- Modify: `app/models/data_research.py`
+- Create: `migrations/versions/20260729_300_device_semantics.py`
+- Modify: `tests/test_database_migrations.py`
+- Create: `tests/integration/test_device_semantics_postgres.py`
+
+**Interfaces:**
+- Produces: `DeviceSemanticCodeService`, `SqlAlchemyDeviceSemanticCodeRepository`, tables `device_semantic_codes`, `device_semantic_code_versions`, and `device_semantic_code_reviews`.
+- Code methods: `create`, `revise`, `submit`, `review`, `search`, `get`, `versions`, and `reviews`.
+
+- [x] **Step 1: Write failing lifecycle and persistence tests**
+
+Cover unique canonical identity, three code types, immutable revisions, optimistic version checks, state transitions, evidence-required rule/AI proposals, secret rejection, accountable-manager approval, immutable review records, filters, and real PostgreSQL cleanup.
+
+- [x] **Step 2: Verify RED**
+
+Run:
+
+```bash
+PYTHONPATH=. .venv/bin/pytest -q \
+  tests/data_research/test_device_semantics.py \
+  tests/test_database_migrations.py::test_device_semantics_migration_is_versioned_reviewed_and_traceable \
+  tests/integration/test_device_semantics_postgres.py
+```
+
+Expected: failure because the schema and SQL repository do not exist.
+
+- [x] **Step 3: Implement migration, models, repository, and lifecycle**
+
+Use UUIDv7 identities, unique ontology/type/code and code/version constraints, JSONB snapshots, immutable review rows, row locks for revise/submit/review, timezone-aware timestamps, and a data-preserving downgrade.
+
+- [x] **Step 4: Upgrade local PostgreSQL and verify GREEN**
+
+Run Alembic to head against the isolated local database, then rerun the Task 2 tests with `TEST_DATABASE_URL` configured.
+
+### Task 3: Permission-Controlled Device Semantic API
+
+**Files:**
+- Modify: `app/api/data_development/routes.py`
+- Modify: `app/core/system/permissions.py`
+- Create: `tests/data_research/test_device_semantic_api.py`
+- Modify: `tests/test_permission_matrix.py`
+
+**Interfaces:**
+- Produces:
+  - `POST /api/development/v1/device-semantics/bootstrap`
+  - `GET /api/development/v1/device-semantics/profile`
+  - `GET|POST /api/development/v1/device-semantics/codes`
+  - `POST /api/development/v1/device-semantics/codes/{code_uid}/revisions`
+  - `POST /api/development/v1/device-semantics/codes/{code_uid}/submit`
+  - `POST /api/development/v1/device-semantics/codes/{code_uid}/review`
+  - `GET /api/development/v1/device-semantics/codes/{code_uid}/versions`
+  - `GET /api/development/v1/device-semantics/codes/{code_uid}/reviews`
+
+- [x] **Step 1: Write failing API and permission tests**
+
+Cover viewer read, viewer write denial, editor create/revise/submit, editor review denial, admin review permission plus accountable-manager domain denial, bounded pagination, profile serialization, immutable version/review responses, and safe error envelopes.
+
+- [x] **Step 2: Verify RED**
+
+Run:
+
+```bash
+PYTHONPATH=. .venv/bin/pytest -q \
+  tests/data_research/test_device_semantic_api.py \
+  tests/test_permission_matrix.py::test_data_development_paths_have_specific_write_policies \
+  tests/test_permission_matrix.py::test_fixed_role_permission_matrix_is_monotonic
+```
+
+Expected: failure because the routes and dedicated permissions do not exist.
+
+- [x] **Step 3: Implement API and authorization**
+
+Add `device-semantics:edit` to editor/admin and `device-semantics:review` to admin. Keep reads on `governance:read`; enforce the accountable-manager check inside the domain boundary, not only in the menu or permission matrix.
+
+- [x] **Step 4: Verify GREEN**
+
+Run the Task 3 test command and expect all tests to pass.
+
+### Task 4: Device Semantic Governance UI and Delivery Evidence
+
+**Files:**
+- Modify: `frontend/src/api/dataDevelopment.js`
+- Modify: `frontend/src/router/routes.js`
+- Modify: `frontend/src/views/dataGovernance/ontology/index.vue`
+- Create: `frontend/src/views/dataGovernance/ontology/deviceSemantics.vue`
+- Modify: `docs/architecture/OPENAPI.yaml`
+- Modify: `docs/architecture/DATA_MODEL.md`
+- Modify: `docs/FUNCTION_MODULE_CENSUS_20260726.md`
+- Modify: `docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md`
+- Modify: `deployment/app/` only for the WP-05 backend subset.
+
+**Interfaces:**
+- Produces: `/data-governance/ontology/device-semantics` with semantic coverage, accountable-manager status, ontology workbench deep link, code filters, create/revise/submit/review actions, source mappings, evidence, version history, and review history.
+
+- [x] **Step 1: Implement the device semantic page**
+
+Add the ontology-center entry and a responsive governance page. The page must expose lifecycle state and audit history, hide mutation actions without permissions, and explain that cross-source matching belongs to WP-06.
+
+- [x] **Step 2: Regenerate contracts and update ledgers**
+
+Run:
+
+```bash
+.venv/bin/python scripts/generate_openapi.py --output docs/architecture/OPENAPI.yaml
+```
+
+Record generic ontology capability as merged/current, WP-05 engineering separately from enterprise code acceptance, and SEM-21 as partial until a governed AI provider and enterprise evidence set are accepted.
+
+- [x] **Step 3: Run targeted verification**
+
+Run only:
+
+```bash
+TEST_DATABASE_URL=postgresql+psycopg2://dataops:dataops-test-password@127.0.0.1:15432/dataops \
+PYTHONPATH=. .venv/bin/pytest -q \
+  tests/data_research/test_device_semantics.py \
+  tests/integration/test_device_semantics_postgres.py \
+  tests/data_research/test_ontology_publication.py \
+  tests/data_research/test_device_semantic_api.py \
+  tests/test_permission_matrix.py \
+  tests/test_database_migrations.py::test_device_semantics_migration_is_versioned_reviewed_and_traceable \
+  tests/test_architecture_artifacts.py
+
+cd frontend
+./node_modules/.bin/eslint \
+  src/api/dataDevelopment.js \
+  src/router/routes.js \
+  src/views/dataGovernance/ontology/index.vue \
+  src/views/dataGovernance/ontology/deviceSemantics.vue
+```
+
+Rebuild only affected local backend/frontend services. In the browser, bootstrap the device ontology, verify profile coverage, configure or confirm the accountable asset manager, create and submit a fault code, approve it, inspect its immutable version/review history, open the generic workbench, and confirm zero console errors. Run `git diff --check`.
+
+- [x] **Step 4: Commit**
+
+Create one independently reversible WP-05 engineering commit. Do not push.

+ 44 - 1
frontend/src/api/dataDevelopment.js

@@ -3,6 +3,7 @@ import http from '@/utils/request'
 const BASE = '/development/v1/ingestion-jobs'
 const ONTOLOGY_BASE = '/development/v1/ontologies'
 const DEVICE_ASSET_BASE = '/development/v1/device-assets'
+const DEVICE_SEMANTIC_BASE = '/development/v1/device-semantics'
 
 export const createIngestionJob = params => http.post(BASE, params)
 export const getIngestionJobs = params => http.get(BASE, params)
@@ -75,6 +76,39 @@ export const importDeviceAssets = params => http.post(
   `${DEVICE_ASSET_BASE}/import`,
   params
 )
+export const getDeviceSemanticProfile = () => http.get(
+  `${DEVICE_SEMANTIC_BASE}/profile`
+)
+export const bootstrapDeviceSemantics = params => http.post(
+  `${DEVICE_SEMANTIC_BASE}/bootstrap`,
+  params
+)
+export const getDeviceSemanticCodes = params => http.get(
+  `${DEVICE_SEMANTIC_BASE}/codes`,
+  params
+)
+export const createDeviceSemanticCode = params => http.post(
+  `${DEVICE_SEMANTIC_BASE}/codes`,
+  params
+)
+export const reviseDeviceSemanticCode = (uid, params) => http.post(
+  `${DEVICE_SEMANTIC_BASE}/codes/${uid}/revisions`,
+  params
+)
+export const submitDeviceSemanticCode = (uid, expectedVersion) => http.post(
+  `${DEVICE_SEMANTIC_BASE}/codes/${uid}/submit`,
+  { expected_version: expectedVersion }
+)
+export const reviewDeviceSemanticCode = (uid, params) => http.post(
+  `${DEVICE_SEMANTIC_BASE}/codes/${uid}/review`,
+  params
+)
+export const getDeviceSemanticCodeVersions = uid => http.get(
+  `${DEVICE_SEMANTIC_BASE}/codes/${uid}/versions`
+)
+export const getDeviceSemanticCodeReviews = uid => http.get(
+  `${DEVICE_SEMANTIC_BASE}/codes/${uid}/reviews`
+)
 
 export default {
   createIngestionJob,
@@ -108,5 +142,14 @@ export default {
   getDeviceAssets,
   getDeviceAsset,
   getDeviceAssetVersions,
-  importDeviceAssets
+  importDeviceAssets,
+  getDeviceSemanticProfile,
+  bootstrapDeviceSemantics,
+  getDeviceSemanticCodes,
+  createDeviceSemanticCode,
+  reviseDeviceSemanticCode,
+  submitDeviceSemanticCode,
+  reviewDeviceSemanticCode,
+  getDeviceSemanticCodeVersions,
+  getDeviceSemanticCodeReviews
 }

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

@@ -266,6 +266,33 @@ export default {
           name: 'ontologyCenter',
           alwaysShow: 0
         },
+        {
+          hidden: 1,
+          icon: 'mdi-graph-outline',
+          type: 1,
+          title: '设备语义与本体',
+          path: '/data-governance/ontology/device-semantics',
+          children: [],
+          enName: 'Device Semantics',
+          label: '设备语义与本体',
+          sort: 0,
+          component: 'dataGovernance/ontology/deviceSemantics',
+          meta: {
+            keepAlive: false,
+            allowClick: false,
+            roles: ['viewer', 'editor', 'admin'],
+            enName: 'Device Semantics',
+            icon: 'mdi-graph-outline',
+            editModules: false,
+            title: '设备语义与本体',
+            fullScreen: false,
+            target: false,
+            effectiveStatus: true,
+            readOnly: 'viewer'
+          },
+          name: 'deviceSemantics',
+          alwaysShow: 0
+        },
         {
           hidden: 1,
           icon: 'mdi-vector-polyline-edit',

+ 834 - 0
frontend/src/views/dataGovernance/ontology/deviceSemantics.vue

@@ -0,0 +1,834 @@
+<template>
+  <div class="device-semantics pa-6">
+    <div class="d-flex flex-wrap align-center mb-6">
+      <div>
+        <div class="text-overline primary--text">WP05 · 设备语义治理</div>
+        <h1 class="text-h4 mb-1">设备语义与本体</h1>
+        <p class="text--secondary mb-0">
+          统一设备、部件、测点、告警、维护与故障处置语义,所有代码变更保留版本和审批证据。
+        </p>
+        <p class="text-caption text--secondary mb-0 mt-1">
+          当前只治理标准语义与来源代码映射;跨来源实体匹配、合并和回滚归入 WP06。
+        </p>
+      </div>
+      <v-spacer />
+      <v-btn
+        v-if="canEdit && !bootstrapped"
+        color="primary"
+        @click="bootstrapDialog = true"
+      >
+        初始化设备语义本体
+      </v-btn>
+      <v-btn
+        v-if="canEdit && bootstrapped"
+        color="primary"
+        @click="openCreate"
+      >
+        新增治理代码
+      </v-btn>
+    </div>
+
+    <v-alert
+      v-if="!loadingProfile && !bootstrapped"
+      type="info"
+      outlined
+      class="mb-5"
+    >
+      尚未建立设备语义本体。初始化后将生成 11 类核心概念、10 条标准关系及平台设备身份映射。
+    </v-alert>
+
+    <v-row v-else class="mb-2">
+      <v-col cols="12" md="5">
+        <v-card outlined class="coverage-card fill-height">
+          <v-card-text>
+            <div class="d-flex align-center">
+              <div>
+                <div class="text-overline">标准语义覆盖度</div>
+                <div class="text-h3 font-weight-bold">
+                  {{ coverage.percent }}%
+                </div>
+              </div>
+              <v-spacer />
+              <v-progress-circular
+                :value="coverage.percent"
+                :color="profile.ready_to_publish ? 'success' : 'warning'"
+                :size="78"
+                :width="8"
+              >
+                {{ coverage.covered }}/{{ coverage.total }}
+              </v-progress-circular>
+            </div>
+            <v-progress-linear
+              class="mt-5"
+              rounded
+              height="8"
+              :value="coverage.percent"
+              :color="profile.ready_to_publish ? 'success' : 'warning'"
+            />
+            <div class="d-flex flex-wrap mt-4">
+              <v-chip small outlined class="mr-2 mb-2">
+                概念 {{ profile.class_count || 0 }}
+              </v-chip>
+              <v-chip small outlined class="mr-2 mb-2">
+                关系 {{ profile.relation_count || 0 }}
+              </v-chip>
+              <v-chip small outlined class="mr-2 mb-2">
+                映射 {{ profile.mapping_count || 0 }}
+              </v-chip>
+              <v-chip
+                small
+                :color="profile.ready_to_publish ? 'success' : 'warning'"
+                text-color="white"
+                class="mb-2"
+              >
+                {{ profile.ready_to_publish ? '满足发布门槛' : '存在语义缺口' }}
+              </v-chip>
+            </div>
+          </v-card-text>
+        </v-card>
+      </v-col>
+      <v-col cols="12" md="7">
+        <v-card outlined class="fill-height">
+          <v-card-text>
+            <div class="text-overline">本体治理边界</div>
+            <div class="text-h6 mb-4">
+              {{ ontology.name || '设备语义本体' }}
+            </div>
+            <v-row dense>
+              <v-col cols="12" sm="6">
+                <div class="field-label">本体编码</div>
+                <div class="mono">{{ ontology.code || 'DEVICE_SEMANTIC' }}</div>
+              </v-col>
+              <v-col cols="12" sm="6">
+                <div class="field-label">当前版本</div>
+                <div>V{{ version.version || 0 }} · {{ version.status || 'draft' }}</div>
+              </v-col>
+              <v-col cols="12">
+                <div class="field-label mt-3">发布约束</div>
+                <div class="text-body-2">
+                  设备语义结构完整后,仍须由责任矩阵中唯一的设备资产负责人执行发布;代码集审批沿用同一责任门禁。
+                </div>
+              </v-col>
+            </v-row>
+            <v-btn
+              text
+              color="primary"
+              class="px-0 mt-3"
+              @click="$router.push(`/data-governance/ontology/${ontology.uid}`)"
+            >
+              进入本体工作台
+              <v-icon right small>mdi-arrow-right</v-icon>
+            </v-btn>
+          </v-card-text>
+        </v-card>
+      </v-col>
+    </v-row>
+
+    <v-card v-if="bootstrapped" outlined>
+      <v-card-title class="d-flex flex-wrap align-center">
+        <div>
+          <div class="text-h6">故障—原因—措施代码集</div>
+          <div class="text-caption text--secondary">
+            规范代码、来源映射、证据、版本与审批状态
+          </div>
+        </div>
+        <v-spacer />
+        <v-chip outlined color="primary">共 {{ total }} 项</v-chip>
+      </v-card-title>
+      <v-divider />
+      <v-card-text>
+        <v-row dense align="center">
+          <v-col cols="12" md="4">
+            <v-text-field
+              v-model.trim="filters.keyword"
+              label="搜索代码、名称或定义"
+              prepend-inner-icon="mdi-magnify"
+              clearable
+              hide-details
+              @keyup.enter="applyFilters"
+            />
+          </v-col>
+          <v-col cols="12" sm="6" md="2">
+            <v-select
+              v-model="filters.code_type"
+              :items="typeOptions"
+              item-text="text"
+              item-value="value"
+              label="代码类型"
+              clearable
+              hide-details
+            />
+          </v-col>
+          <v-col cols="12" sm="6" md="2">
+            <v-select
+              v-model="filters.status"
+              :items="statusOptions"
+              item-text="text"
+              item-value="value"
+              label="治理状态"
+              clearable
+              hide-details
+            />
+          </v-col>
+          <v-col cols="12" md="4">
+            <v-btn color="primary" class="mr-2" @click="applyFilters">
+              查询
+            </v-btn>
+            <v-btn text @click="resetFilters">重置</v-btn>
+          </v-col>
+        </v-row>
+      </v-card-text>
+      <v-data-table
+        :headers="headers"
+        :items="codes"
+        :loading="loadingCodes"
+        :items-per-page="pageSize"
+        hide-default-footer
+      >
+        <template v-slot:[`item.canonical_name`]="{ item }">
+          <div class="py-2">
+            <div class="font-weight-medium">{{ item.canonical_name }}</div>
+            <div class="text-caption mono">{{ item.canonical_code }}</div>
+          </div>
+        </template>
+        <template v-slot:[`item.code_type`]="{ item }">
+          <v-chip small outlined color="primary">
+            {{ codeTypeLabel(item.code_type) }}
+          </v-chip>
+        </template>
+        <template v-slot:[`item.status`]="{ item }">
+          <v-chip
+            small
+            :color="codeStatusColor(item.status)"
+            text-color="white"
+          >
+            {{ codeStatusLabel(item.status) }}
+          </v-chip>
+        </template>
+        <template v-slot:[`item.suggestion_source`]="{ item }">
+          <div>{{ sourceLabel(item.suggestion_source) }}</div>
+          <div class="text-caption text--secondary">
+            证据 {{ (item.evidence_uids || []).length }} 项
+          </div>
+        </template>
+        <template v-slot:[`item.current_version`]="{ item }">
+          V{{ item.current_version }}
+        </template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <div class="action-row">
+            <v-btn small text color="primary" @click="openTrace(item)">
+              追溯
+            </v-btn>
+            <v-btn
+              v-if="canEdit && ['draft', 'rejected'].includes(item.status)"
+              small
+              text
+              color="primary"
+              @click="openEdit(item)"
+            >
+              修订
+            </v-btn>
+            <v-btn
+              v-if="canEdit && item.status === 'draft'"
+              small
+              text
+              color="primary"
+              @click="submitCode(item)"
+            >
+              提交
+            </v-btn>
+            <v-btn
+              v-if="canReview && item.status === 'in_review'"
+              small
+              text
+              color="success"
+              @click="openReview(item)"
+            >
+              审批
+            </v-btn>
+          </div>
+        </template>
+        <template v-slot:no-data>
+          <div class="py-10 text--secondary">暂无符合条件的治理代码</div>
+        </template>
+      </v-data-table>
+      <v-divider />
+      <div class="d-flex align-center pa-4">
+        <span class="text-caption text--secondary">
+          第 {{ page }} 页,每页 {{ pageSize }} 项
+        </span>
+        <v-spacer />
+        <v-pagination
+          v-model="page"
+          :length="pageCount"
+          :total-visible="7"
+          @input="loadCodes"
+        />
+      </div>
+    </v-card>
+
+    <v-dialog v-model="bootstrapDialog" max-width="600">
+      <v-card>
+        <v-card-title>初始化设备语义本体</v-card-title>
+        <v-card-text>
+          <v-alert type="info" outlined dense>
+            初始化操作具有幂等性,不会重复创建本体或草稿版本。
+          </v-alert>
+          <v-text-field
+            v-model.trim="bootstrapForm.owner_domain_uid"
+            label="主责业务域 UID"
+            hint="必填,用于绑定设备本体主责业务域"
+            persistent-hint
+          />
+          <v-text-field
+            v-model.trim="bootstrapForm.owner_uid"
+            label="本体责任人 UID(可选)"
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="bootstrapDialog = false">取消</v-btn>
+          <v-btn
+            color="primary"
+            :loading="saving"
+            :disabled="!bootstrapForm.owner_domain_uid"
+            @click="bootstrap"
+          >
+            确认初始化
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="codeDialog" max-width="760" scrollable>
+      <v-card>
+        <v-card-title>
+          {{ editingCode ? '修订治理代码' : '新增治理代码' }}
+        </v-card-title>
+        <v-card-text>
+          <v-row>
+            <v-col cols="12" sm="4">
+              <v-select
+                v-model="codeForm.code_type"
+                :items="typeOptions"
+                item-text="text"
+                item-value="value"
+                label="代码类型"
+                :disabled="Boolean(editingCode)"
+              />
+            </v-col>
+            <v-col cols="12" sm="8">
+              <v-text-field
+                v-model.trim="codeForm.canonical_code"
+                label="规范代码"
+                :disabled="Boolean(editingCode)"
+              />
+            </v-col>
+          </v-row>
+          <v-text-field
+            v-model.trim="codeForm.canonical_name"
+            label="规范名称"
+          />
+          <v-textarea
+            v-model.trim="codeForm.definition"
+            label="语义定义"
+            rows="3"
+            auto-grow
+          />
+          <v-row>
+            <v-col cols="12" sm="5">
+              <v-select
+                v-model="codeForm.suggestion_source"
+                :items="sourceOptions"
+                item-text="text"
+                item-value="value"
+                label="建议来源"
+              />
+            </v-col>
+            <v-col cols="12" sm="7">
+              <v-text-field
+                v-if="codeForm.suggestion_source === 'ai'"
+                v-model="codeForm.confidence"
+                type="number"
+                min="0"
+                max="1"
+                step="0.01"
+                label="AI 置信度"
+              />
+            </v-col>
+          </v-row>
+          <v-textarea
+            v-model="evidenceText"
+            label="证据 UID"
+            hint="每行一个;规则或 AI 建议必须提供证据"
+            persistent-hint
+            rows="3"
+          />
+          <div class="text-subtitle-2 mt-5 mb-2">来源代码映射(可选)</div>
+          <v-row dense>
+            <v-col cols="12" sm="7">
+              <v-text-field
+                v-model.trim="sourceMapping.source_uid"
+                label="来源 UID"
+                hide-details
+              />
+            </v-col>
+            <v-col cols="12" sm="5">
+              <v-text-field
+                v-model.trim="sourceMapping.source_code"
+                label="来源代码"
+                hide-details
+              />
+            </v-col>
+          </v-row>
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="codeDialog = false">取消</v-btn>
+          <v-btn
+            color="primary"
+            :loading="saving"
+            :disabled="!codeForm.canonical_name || !codeForm.canonical_code"
+            @click="saveCode"
+          >
+            保存草稿
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="reviewDialog" max-width="620">
+      <v-card>
+        <v-card-title>审批治理代码</v-card-title>
+        <v-card-text>
+          <v-alert type="warning" outlined dense>
+            后端将再次校验你是否为该设备本体责任矩阵中的唯一设备资产负责人。
+          </v-alert>
+          <v-radio-group v-model="reviewForm.decision" row>
+            <v-radio label="批准发布" value="approve" />
+            <v-radio label="退回修订" value="reject" />
+          </v-radio-group>
+          <v-textarea
+            v-model.trim="reviewForm.reason"
+            label="审批意见"
+            :hint="reviewForm.decision === 'reject' ? '退回时必填' : '建议填写审批依据'"
+            persistent-hint
+            rows="3"
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="reviewDialog = false">取消</v-btn>
+          <v-btn
+            color="primary"
+            :loading="saving"
+            :disabled="reviewForm.decision === 'reject' && !reviewForm.reason"
+            @click="reviewCode"
+          >
+            提交审批
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="traceDialog" max-width="900" scrollable>
+      <v-card>
+        <v-card-title>
+          {{ selectedCode ? selectedCode.canonical_name : '代码追溯' }}
+          <v-spacer />
+          <v-btn icon @click="traceDialog = false">
+            <v-icon>mdi-close</v-icon>
+          </v-btn>
+        </v-card-title>
+        <v-divider />
+        <v-card-text class="pt-5">
+          <v-progress-linear v-if="traceLoading" indeterminate />
+          <template v-else>
+            <h3 class="text-subtitle-1 font-weight-bold mb-2">不可变版本</h3>
+            <v-timeline dense align-top>
+              <v-timeline-item
+                v-for="item in traceVersions"
+                :key="item.uid"
+                small
+                color="primary"
+              >
+                <strong>V{{ item.version }}</strong>
+                <span class="text-caption text--secondary ml-2">
+                  {{ displayTime(item.created_at) }}
+                </span>
+                <div class="text-body-2 mt-1">
+                  {{ item.snapshot.canonical_name }}
+                  · {{ item.snapshot.canonical_code }}
+                </div>
+              </v-timeline-item>
+            </v-timeline>
+            <h3 class="text-subtitle-1 font-weight-bold mt-6 mb-2">审批记录</h3>
+            <v-simple-table dense>
+              <thead>
+                <tr>
+                  <th>版本</th>
+                  <th>结论</th>
+                  <th>审批人</th>
+                  <th>意见</th>
+                  <th>时间</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="item in traceReviews" :key="item.uid">
+                  <td>V{{ item.version }}</td>
+                  <td>{{ item.decision === 'approve' ? '批准' : '退回' }}</td>
+                  <td class="mono">{{ item.actor_uid }}</td>
+                  <td>{{ item.reason || '—' }}</td>
+                  <td>{{ displayTime(item.created_at) }}</td>
+                </tr>
+                <tr v-if="!traceReviews.length">
+                  <td colspan="5" class="text-center text--secondary py-5">
+                    暂无审批记录
+                  </td>
+                </tr>
+              </tbody>
+            </v-simple-table>
+          </template>
+        </v-card-text>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  bootstrapDeviceSemantics,
+  createDeviceSemanticCode,
+  getDeviceSemanticCodeReviews,
+  getDeviceSemanticCodes,
+  getDeviceSemanticCodeVersions,
+  getDeviceSemanticProfile,
+  reviseDeviceSemanticCode,
+  reviewDeviceSemanticCode,
+  submitDeviceSemanticCode
+} from '@/api/dataDevelopment'
+import {
+  canEditDeviceSemantics,
+  canReviewDeviceSemantics,
+  codeStatusColor,
+  codeStatusLabel,
+  codeTypeLabel,
+  semanticCoverage
+} from './deviceSemanticsModel'
+
+export default {
+  name: 'DeviceSemantics',
+  data: () => ({
+    loadingProfile: false,
+    loadingCodes: false,
+    saving: false,
+    traceLoading: false,
+    bootstrapped: false,
+    ontology: {},
+    version: {},
+    profile: {},
+    codes: [],
+    total: 0,
+    page: 1,
+    pageSize: 20,
+    bootstrapDialog: false,
+    codeDialog: false,
+    reviewDialog: false,
+    traceDialog: false,
+    editingCode: null,
+    selectedCode: null,
+    traceVersions: [],
+    traceReviews: [],
+    bootstrapForm: {
+      owner_domain_uid: '',
+      owner_uid: ''
+    },
+    filters: {
+      keyword: '',
+      code_type: null,
+      status: null
+    },
+    codeForm: {
+      code_type: 'fault',
+      canonical_code: '',
+      canonical_name: '',
+      definition: '',
+      suggestion_source: 'manual',
+      confidence: null
+    },
+    evidenceText: '',
+    sourceMapping: {
+      source_uid: '',
+      source_code: ''
+    },
+    reviewForm: {
+      decision: 'approve',
+      reason: ''
+    },
+    typeOptions: [
+      { text: '故障', value: 'fault' },
+      { text: '原因', value: 'cause' },
+      { text: '措施', value: 'action' }
+    ],
+    statusOptions: [
+      { text: '草稿', value: 'draft' },
+      { text: '待审批', value: 'in_review' },
+      { text: '已发布', value: 'published' },
+      { text: '已退回', value: 'rejected' },
+      { text: '已停用', value: 'retired' }
+    ],
+    sourceOptions: [
+      { text: '人工录入', value: 'manual' },
+      { text: '规则建议', value: 'rule' },
+      { text: 'AI 建议', value: 'ai' }
+    ],
+    headers: [
+      { text: '规范名称 / 代码', value: 'canonical_name' },
+      { text: '类型', value: 'code_type' },
+      { text: '定义', value: 'definition' },
+      { text: '建议来源 / 证据', value: 'suggestion_source' },
+      { text: '状态', value: 'status' },
+      { text: '版本', value: 'current_version' },
+      { text: '操作', value: 'actions', sortable: false, width: 220 }
+    ]
+  }),
+  computed: {
+    permissions () {
+      return (this.$store.state.user.userInfo || {}).permissions || []
+    },
+    canEdit () {
+      return canEditDeviceSemantics(this.permissions)
+    },
+    canReview () {
+      return canReviewDeviceSemantics(this.permissions)
+    },
+    coverage () {
+      return semanticCoverage(this.profile)
+    },
+    pageCount () {
+      return Math.max(1, Math.ceil(this.total / this.pageSize))
+    }
+  },
+  created () {
+    this.loadProfile()
+  },
+  methods: {
+    codeTypeLabel,
+    codeStatusLabel,
+    codeStatusColor,
+    async loadProfile () {
+      this.loadingProfile = true
+      try {
+        const response = await getDeviceSemanticProfile()
+        const data = response.data || {}
+        this.bootstrapped = Boolean(data.bootstrapped)
+        this.ontology = data.ontology || {}
+        this.version = data.version || {}
+        this.profile = data.profile || {}
+        if (this.bootstrapped) await this.loadCodes()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loadingProfile = false
+      }
+    },
+    async bootstrap () {
+      this.saving = true
+      try {
+        await bootstrapDeviceSemantics({
+          owner_domain_uid: this.bootstrapForm.owner_domain_uid,
+          ...(this.bootstrapForm.owner_uid
+            ? { owner_uid: this.bootstrapForm.owner_uid }
+            : {})
+        })
+        this.bootstrapDialog = false
+        await this.loadProfile()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    async loadCodes () {
+      if (!this.ontology.uid) return
+      this.loadingCodes = true
+      try {
+        const response = await getDeviceSemanticCodes({
+          ontology_uid: this.ontology.uid,
+          ...this.filters,
+          page: this.page,
+          page_size: this.pageSize
+        })
+        this.codes = (response.data && response.data.records) || []
+        this.total = Number((response.data && response.data.total) || 0)
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loadingCodes = false
+      }
+    },
+    applyFilters () {
+      this.page = 1
+      this.loadCodes()
+    },
+    resetFilters () {
+      this.filters = { keyword: '', code_type: null, status: null }
+      this.applyFilters()
+    },
+    openCreate () {
+      this.editingCode = null
+      this.codeForm = {
+        code_type: 'fault',
+        canonical_code: '',
+        canonical_name: '',
+        definition: '',
+        suggestion_source: 'manual',
+        confidence: null
+      }
+      this.evidenceText = ''
+      this.sourceMapping = { source_uid: '', source_code: '' }
+      this.codeDialog = true
+    },
+    openEdit (item) {
+      const mapping = (item.source_mappings || [])[0] || {}
+      this.editingCode = item
+      this.codeForm = {
+        code_type: item.code_type,
+        canonical_code: item.canonical_code,
+        canonical_name: item.canonical_name,
+        definition: item.definition || '',
+        suggestion_source: item.suggestion_source || 'manual',
+        confidence: item.confidence
+      }
+      this.evidenceText = (item.evidence_uids || []).join('\n')
+      this.sourceMapping = {
+        source_uid: mapping.source_uid || '',
+        source_code: mapping.source_code || ''
+      }
+      this.codeDialog = true
+    },
+    codePayload () {
+      const sourceMappings = (
+        this.sourceMapping.source_uid && this.sourceMapping.source_code
+          ? [{ ...this.sourceMapping }]
+          : []
+      )
+      return {
+        ontology_uid: this.ontology.uid,
+        ...this.codeForm,
+        confidence: this.codeForm.confidence === ''
+          ? null
+          : this.codeForm.confidence,
+        evidence_uids: this.evidenceText
+          .split('\n')
+          .map(item => item.trim())
+          .filter(Boolean),
+        source_mappings: sourceMappings
+      }
+    },
+    async saveCode () {
+      this.saving = true
+      try {
+        if (this.editingCode) {
+          await reviseDeviceSemanticCode(this.editingCode.uid, {
+            ...this.codePayload(),
+            expected_version: this.editingCode.current_version
+          })
+        } else {
+          await createDeviceSemanticCode(this.codePayload())
+        }
+        this.codeDialog = false
+        await this.loadCodes()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    async submitCode (item) {
+      try {
+        await submitDeviceSemanticCode(item.uid, item.current_version)
+        await this.loadCodes()
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    openReview (item) {
+      this.selectedCode = item
+      this.reviewForm = { decision: 'approve', reason: '' }
+      this.reviewDialog = true
+    },
+    async reviewCode () {
+      this.saving = true
+      try {
+        await reviewDeviceSemanticCode(this.selectedCode.uid, {
+          ...this.reviewForm,
+          expected_version: this.selectedCode.current_version
+        })
+        this.reviewDialog = false
+        await this.loadCodes()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    async openTrace (item) {
+      this.selectedCode = item
+      this.traceDialog = true
+      this.traceLoading = true
+      this.traceVersions = []
+      this.traceReviews = []
+      try {
+        const [versions, reviews] = await Promise.all([
+          getDeviceSemanticCodeVersions(item.uid),
+          getDeviceSemanticCodeReviews(item.uid)
+        ])
+        this.traceVersions = (versions.data && versions.data.records) || []
+        this.traceReviews = (reviews.data && reviews.data.records) || []
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.traceLoading = false
+      }
+    },
+    sourceLabel (value) {
+      const item = this.sourceOptions.find(option => option.value === value)
+      return item ? item.text : value
+    },
+    displayTime (value) {
+      if (!value) return '—'
+      return new Date(value).toLocaleString('zh-CN', { hour12: false })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.device-semantics {
+  max-width: 1520px;
+  margin: 0 auto;
+}
+
+.coverage-card {
+  background:
+    radial-gradient(circle at 90% 10%, rgba(25, 118, 210, 0.12), transparent 42%),
+    #fff;
+}
+
+.field-label {
+  color: rgba(0, 0, 0, 0.58);
+  font-size: 12px;
+  margin-bottom: 4px;
+}
+
+.mono {
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 12px;
+}
+
+.action-row {
+  display: flex;
+  flex-wrap: wrap;
+}
+</style>

+ 62 - 0
frontend/src/views/dataGovernance/ontology/deviceSemanticsModel.js

@@ -0,0 +1,62 @@
+const TYPE_LABELS = {
+  fault: '故障',
+  cause: '原因',
+  action: '措施'
+}
+
+const STATUS_LABELS = {
+  draft: '草稿',
+  in_review: '待审批',
+  published: '已发布',
+  rejected: '已退回',
+  retired: '已停用'
+}
+
+const STATUS_COLORS = {
+  draft: 'blue-grey',
+  in_review: 'amber darken-2',
+  published: 'success',
+  rejected: 'error',
+  retired: 'grey'
+}
+
+export function canEditDeviceSemantics (permissions) {
+  return (permissions || []).includes('device-semantics:edit')
+}
+
+export function canReviewDeviceSemantics (permissions) {
+  return (permissions || []).includes('device-semantics:review')
+}
+
+export function codeTypeLabel (value) {
+  return TYPE_LABELS[value] || value || '—'
+}
+
+export function codeStatusLabel (value) {
+  return STATUS_LABELS[value] || value || '—'
+}
+
+export function codeStatusColor (value) {
+  return STATUS_COLORS[value] || 'grey'
+}
+
+export function semanticCoverage (profile) {
+  const value = profile || {}
+  const covered = (
+    Number(value.class_count || 0) +
+    Number(value.relation_count || 0) +
+    Number(value.mapping_count || 0)
+  )
+  const missing = (
+    (value.missing_classes || []).length +
+    (value.missing_relations || []).length +
+    (value.missing_mappings || []).length
+  )
+  const total = covered + missing
+  return {
+    covered,
+    missing,
+    total,
+    percent: total ? Math.round(covered * 100 / total) : 0
+  }
+}

+ 8 - 0
frontend/src/views/dataGovernance/ontology/index.vue

@@ -6,6 +6,14 @@
         <p class="text--secondary mb-0">跨业务域管理本体定义、版本与发布状态</p>
       </div>
       <v-spacer />
+      <v-btn
+        outlined
+        color="primary"
+        class="mr-3"
+        @click="$router.push('/data-governance/ontology/device-semantics')"
+      >
+        设备语义治理
+      </v-btn>
       <v-btn v-if="canEdit" color="primary" @click="openCreate">新建本体</v-btn>
     </div>
     <v-data-table :headers="headers" :items="items" :loading="loading">

+ 51 - 0
frontend/tests/device-semantics-model.test.mjs

@@ -0,0 +1,51 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import {
+  canEditDeviceSemantics,
+  canReviewDeviceSemantics,
+  codeStatusLabel,
+  codeTypeLabel,
+  semanticCoverage
+} from '../src/views/dataGovernance/ontology/deviceSemanticsModel.js'
+
+test('separates device semantic editing from accountable review', () => {
+  assert.equal(
+    canEditDeviceSemantics(['device-semantics:edit']),
+    true
+  )
+  assert.equal(
+    canReviewDeviceSemantics(['device-semantics:edit']),
+    false
+  )
+  assert.equal(
+    canReviewDeviceSemantics(['device-semantics:review']),
+    true
+  )
+})
+
+test('presents governed code types and workflow states in Chinese', () => {
+  assert.equal(codeTypeLabel('fault'), '故障')
+  assert.equal(codeTypeLabel('cause'), '原因')
+  assert.equal(codeTypeLabel('action'), '措施')
+  assert.equal(codeStatusLabel('in_review'), '待审批')
+  assert.equal(codeStatusLabel('published'), '已发布')
+})
+
+test('summarizes semantic coverage without dividing by zero', () => {
+  assert.deepEqual(
+    semanticCoverage({
+      class_count: 11,
+      relation_count: 10,
+      mapping_count: 6,
+      missing_classes: [],
+      missing_relations: [],
+      missing_mappings: []
+    }),
+    { covered: 27, missing: 0, total: 27, percent: 100 }
+  )
+  assert.deepEqual(
+    semanticCoverage({}),
+    { covered: 0, missing: 0, total: 0, percent: 0 }
+  )
+})

+ 91 - 0
migrations/versions/20260729_300_device_semantics.py

@@ -0,0 +1,91 @@
+"""Add governed device semantic codes, immutable versions, and reviews."""
+
+from alembic import op
+
+revision = "20260729_300"
+down_revision = "20260729_290"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.device_semantic_codes (
+            uid UUID PRIMARY KEY,
+            ontology_uid UUID NOT NULL
+                REFERENCES public.ontologies(uid) ON DELETE RESTRICT,
+            code_type VARCHAR(20) NOT NULL
+                CHECK (code_type IN ('fault','cause','action')),
+            canonical_code VARCHAR(120) NOT NULL,
+            canonical_name VARCHAR(300) NOT NULL,
+            definition TEXT,
+            status VARCHAR(20) NOT NULL DEFAULT 'draft'
+                CHECK (
+                    status IN (
+                        'draft','in_review','published','rejected','retired'
+                    )
+                ),
+            current_version INTEGER NOT NULL DEFAULT 1
+                CHECK (current_version > 0),
+            source_mappings JSONB NOT NULL DEFAULT '[]'::jsonb,
+            evidence_uids JSONB NOT NULL DEFAULT '[]'::jsonb,
+            suggestion_source VARCHAR(20) NOT NULL DEFAULT 'manual'
+                CHECK (suggestion_source IN ('manual','rule','ai')),
+            confidence DOUBLE PRECISION
+                CHECK (
+                    confidence IS NULL
+                    OR (confidence >= 0 AND confidence <= 1)
+                ),
+            created_by VARCHAR(100),
+            updated_by VARCHAR(100),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (ontology_uid, code_type, canonical_code)
+        );
+        CREATE INDEX idx_device_semantic_codes_lookup
+            ON public.device_semantic_codes(
+                ontology_uid, code_type, status, canonical_code
+            );
+        CREATE INDEX idx_device_semantic_codes_updated
+            ON public.device_semantic_codes(updated_at DESC);
+
+        CREATE TABLE public.device_semantic_code_versions (
+            uid UUID PRIMARY KEY,
+            code_uid UUID NOT NULL
+                REFERENCES public.device_semantic_codes(uid)
+                ON DELETE CASCADE,
+            version INTEGER NOT NULL CHECK (version > 0),
+            snapshot JSONB NOT NULL,
+            created_by VARCHAR(100),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (code_uid, version)
+        );
+        CREATE INDEX idx_device_semantic_code_versions_created
+            ON public.device_semantic_code_versions(
+                code_uid, created_at DESC
+            );
+
+        CREATE TABLE public.device_semantic_code_reviews (
+            uid UUID PRIMARY KEY,
+            code_uid UUID NOT NULL
+                REFERENCES public.device_semantic_codes(uid)
+                ON DELETE CASCADE,
+            version INTEGER NOT NULL CHECK (version > 0),
+            decision VARCHAR(20) NOT NULL
+                CHECK (decision IN ('approve','reject')),
+            reason VARCHAR(1000) NOT NULL DEFAULT '',
+            actor_uid VARCHAR(100) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_device_semantic_code_reviews_created
+            ON public.device_semantic_code_reviews(
+                code_uid, created_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Governed code dictionaries and their approval evidence are retained.
+    pass

+ 315 - 0
tests/data_research/test_device_semantic_api.py

@@ -0,0 +1,315 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from datetime import UTC, datetime
+
+import pytest
+
+ONTOLOGY_UID = "01900000-0000-7000-8000-000000000601"
+CODE_UID = "01900000-0000-7000-8000-000000000602"
+
+
+class FakeDeviceSemanticService:
+    def __init__(self):
+        from app.core.data_research.device_semantics import (
+            DeviceSemanticBootstrapResult,
+            assess_device_graph,
+            build_device_graph,
+        )
+        from app.core.data_research.ontology.models import (
+            DomainLink,
+            GraphDocument,
+            Ontology,
+            OntologyVersion,
+        )
+
+        graph = build_device_graph(
+            "01900000-0000-7000-8000-000000000603"
+        )
+        ontology = Ontology(
+            uid=ONTOLOGY_UID,
+            code="DEVICE_SEMANTIC",
+            name="设备语义本体",
+            owner_uid="editor-1",
+            domain_links=(
+                DomainLink(
+                    "01900000-0000-7000-8000-000000000603",
+                    "owner",
+                ),
+            ),
+        )
+        version = OntologyVersion(
+            uid="01900000-0000-7000-8000-000000000604",
+            ontology_uid=ONTOLOGY_UID,
+            version=1,
+            graph_document=GraphDocument.from_dict(graph),
+            content_hash="a" * 64,
+        )
+        self.result = DeviceSemanticBootstrapResult(
+            ontology=ontology,
+            version=version,
+            profile=assess_device_graph(graph),
+            created=True,
+        )
+        self.actions = []
+
+    def bootstrap(self, payload, actor_uid):
+        self.actions.append(("bootstrap", payload, actor_uid))
+        return self.result
+
+    def profile(self):
+        self.actions.append(("profile",))
+        return replace(self.result, created=False)
+
+
+class FakeDeviceSemanticCodeService:
+    def __init__(self):
+        from app.core.data_research.device_semantics import (
+            DeviceSemanticCodeRecord,
+            DeviceSemanticCodeReviewRecord,
+            DeviceSemanticCodeVersionRecord,
+        )
+
+        now = datetime(2026, 7, 29, 16, 0, tzinfo=UTC)
+        self.record = DeviceSemanticCodeRecord(
+            uid=CODE_UID,
+            ontology_uid=ONTOLOGY_UID,
+            code_type="fault",
+            canonical_code="F-001",
+            canonical_name="轴承过热",
+            definition="轴承温度超过治理阈值",
+            status="draft",
+            current_version=1,
+            source_mappings=(),
+            evidence_uids=("evidence-1",),
+            suggestion_source="rule",
+            confidence=None,
+            created_by="editor-1",
+            updated_by="editor-1",
+            created_at=now,
+            updated_at=now,
+        )
+        self.version = DeviceSemanticCodeVersionRecord(
+            uid="01900000-0000-7000-8000-000000000605",
+            code_uid=CODE_UID,
+            version=1,
+            snapshot={
+                "ontology_uid": ONTOLOGY_UID,
+                "code_type": "fault",
+                "canonical_code": "F-001",
+                "canonical_name": "轴承过热",
+            },
+            created_by="editor-1",
+            created_at=now,
+        )
+        self.review_record = DeviceSemanticCodeReviewRecord(
+            uid="01900000-0000-7000-8000-000000000606",
+            code_uid=CODE_UID,
+            version=1,
+            decision="approve",
+            reason="企业故障字典已确认",
+            actor_uid="admin-1",
+            created_at=now,
+        )
+        self.actions = []
+
+    def search(self, filters, *, page, page_size):
+        self.actions.append(("search", filters, page, page_size))
+        return [self.record], 1
+
+    def create(self, payload, actor_uid):
+        self.actions.append(("create", payload, actor_uid))
+        return self.record
+
+    def get(self, code_uid):
+        self.actions.append(("get", code_uid))
+        return self.record
+
+    def revise(
+        self,
+        code_uid,
+        payload,
+        *,
+        expected_version,
+        actor_uid,
+    ):
+        self.actions.append(
+            ("revise", code_uid, payload, expected_version, actor_uid)
+        )
+        return replace(
+            self.record,
+            canonical_name=payload.get(
+                "canonical_name",
+                self.record.canonical_name,
+            ),
+            current_version=2,
+        )
+
+    def submit(self, code_uid, *, expected_version, actor_uid):
+        self.actions.append(
+            ("submit", code_uid, expected_version, actor_uid)
+        )
+        return replace(self.record, status="in_review")
+
+    def review(self, code_uid, payload, *, actor_uid):
+        self.actions.append(("review", code_uid, payload, actor_uid))
+        return replace(self.record, status="published"), self.review_record
+
+    def versions(self, code_uid):
+        self.actions.append(("versions", code_uid))
+        return [self.version]
+
+    def reviews(self, code_uid):
+        self.actions.append(("reviews", code_uid))
+        return [self.review_record]
+
+
+@pytest.fixture()
+def client(monkeypatch):
+    from flask import request
+
+    from app import create_app
+    from app.api.data_development import routes
+    from app.core.system import permissions
+
+    semantic_service = FakeDeviceSemanticService()
+    code_service = FakeDeviceSemanticCodeService()
+
+    def identity():
+        role = request.headers.get(
+            "Authorization",
+            "",
+        ).removeprefix("Bearer ")
+        if role not in {"viewer", "editor", "admin"}:
+            return None
+        return {"id": f"{role}-1", "roles": [role]}
+
+    monkeypatch.setattr(permissions, "authenticate_request", identity)
+    monkeypatch.setattr(
+        routes,
+        "get_device_semantic_service",
+        lambda: semantic_service,
+    )
+    monkeypatch.setattr(
+        routes,
+        "get_device_semantic_code_service",
+        lambda: code_service,
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    return app.test_client(), semantic_service, code_service
+
+
+def test_viewer_reads_profile_codes_and_traceability_but_cannot_edit(client):
+    http, _semantic_service, code_service = client
+    headers = {"Authorization": "Bearer viewer"}
+
+    profile = http.get(
+        "/api/development/v1/device-semantics/profile",
+        headers=headers,
+    )
+    codes = http.get(
+        "/api/development/v1/device-semantics/codes"
+        f"?ontology_uid={ONTOLOGY_UID}&code_type=fault&page=1&page_size=10",
+        headers=headers,
+    )
+    versions = http.get(
+        f"/api/development/v1/device-semantics/codes/{CODE_UID}/versions",
+        headers=headers,
+    )
+    reviews = http.get(
+        f"/api/development/v1/device-semantics/codes/{CODE_UID}/reviews",
+        headers=headers,
+    )
+    denied = http.post(
+        "/api/development/v1/device-semantics/codes",
+        headers=headers,
+        json={},
+    )
+
+    assert profile.status_code == 200
+    assert profile.get_json()["data"]["profile"]["ready_to_publish"] is True
+    assert codes.status_code == 200
+    assert codes.get_json()["data"]["records"][0][
+        "canonical_code"
+    ] == "F-001"
+    assert versions.get_json()["data"]["records"][0]["version"] == 1
+    assert reviews.get_json()["data"]["records"][0][
+        "decision"
+    ] == "approve"
+    assert denied.status_code == 403
+    assert code_service.actions[0] == (
+        "search",
+        {"ontology_uid": ONTOLOGY_UID, "code_type": "fault"},
+        "1",
+        "10",
+    )
+
+
+def test_editor_bootstraps_creates_and_submits_but_cannot_review(client):
+    http, semantic_service, code_service = client
+    headers = {"Authorization": "Bearer editor"}
+
+    bootstrap = http.post(
+        "/api/development/v1/device-semantics/bootstrap",
+        headers=headers,
+        json={
+            "owner_domain_uid": (
+                "01900000-0000-7000-8000-000000000603"
+            )
+        },
+    )
+    created = http.post(
+        "/api/development/v1/device-semantics/codes",
+        headers=headers,
+        json={
+            "ontology_uid": ONTOLOGY_UID,
+            "code_type": "fault",
+            "canonical_code": "F-001",
+            "canonical_name": "轴承过热",
+        },
+    )
+    submitted = http.post(
+        f"/api/development/v1/device-semantics/codes/{CODE_UID}/submit",
+        headers=headers,
+        json={"expected_version": 1},
+    )
+    denied = http.post(
+        f"/api/development/v1/device-semantics/codes/{CODE_UID}/review",
+        headers=headers,
+        json={
+            "decision": "approve",
+            "expected_version": 1,
+        },
+    )
+
+    assert bootstrap.status_code == 201
+    assert created.status_code == 201
+    assert submitted.get_json()["data"]["status"] == "in_review"
+    assert denied.status_code == 403
+    assert semantic_service.actions[0][2] == "editor-1"
+    assert code_service.actions[-1] == (
+        "submit",
+        CODE_UID,
+        1,
+        "editor-1",
+    )
+
+
+def test_admin_review_returns_audited_decision(client):
+    http, _semantic_service, code_service = client
+
+    response = http.post(
+        f"/api/development/v1/device-semantics/codes/{CODE_UID}/review",
+        headers={"Authorization": "Bearer admin"},
+        json={
+            "decision": "approve",
+            "reason": "企业故障字典已确认",
+            "expected_version": 1,
+        },
+    )
+
+    assert response.status_code == 200
+    assert response.get_json()["data"]["record"]["status"] == "published"
+    assert response.get_json()["data"]["review"]["actor_uid"] == "admin-1"
+    assert code_service.actions[-1][3] == "admin-1"

+ 581 - 0
tests/data_research/test_device_semantics.py

@@ -0,0 +1,581 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+DOMAIN_UID = "01900000-0000-7000-8000-000000000501"
+MANAGER_UID = "01900000-0000-7000-8000-000000000502"
+OTHER_UID = "01900000-0000-7000-8000-000000000503"
+SOURCE_UID = "01900000-0000-7000-8000-000000000504"
+
+
+def memory_repository():
+    from app.core.data_research.ontology.repository import (
+        MemoryOntologyRepository,
+    )
+
+    ids = iter(
+        (
+            "01900000-0000-7000-8000-000000000511",
+            "01900000-0000-7000-8000-000000000512",
+            "01900000-0000-7000-8000-000000000513",
+            "01900000-0000-7000-8000-000000000514",
+        )
+    )
+    return MemoryOntologyRepository(uid_factory=ids.__next__)
+
+
+def responsibility_lookup(manager_uid=MANAGER_UID):
+    def lookup(resource_type, resource_uid):
+        assert resource_type == "device_ontology"
+        assert resource_uid
+        return {
+            "resource_type": resource_type,
+            "resource_uid": resource_uid,
+            "revision": 1,
+            "assignments": [
+                {
+                    "user_id": manager_uid,
+                    "responsibility_role": "asset_manager",
+                    "raci_role": "accountable",
+                }
+            ],
+        }
+
+    return lookup
+
+
+def test_device_graph_is_deterministic_complete_and_generically_valid():
+    from app.core.data_research.device_semantics import (
+        assess_device_graph,
+        build_device_graph,
+    )
+    from app.core.data_research.ontology.validation import validate_graph
+
+    first = build_device_graph(DOMAIN_UID)
+    second = build_device_graph(DOMAIN_UID)
+    profile = assess_device_graph(first)
+
+    assert first == second
+    assert {item["uid"] for item in first["classes"]} == {
+        "device",
+        "component",
+        "measurement_point",
+        "alarm",
+        "maintenance_record",
+        "location",
+        "organization",
+        "person",
+        "fault",
+        "cause",
+        "action",
+    }
+    assert {
+        item["uid"] for item in first["relations"]
+    } >= {
+        "component.part_of",
+        "measurement_point.monitors",
+        "alarm.raised_by",
+        "maintenance_record.maintains",
+        "device.located_at",
+        "device.owned_by",
+        "device.responsible_to",
+        "alarm.classified_as",
+        "fault.caused_by",
+        "maintenance_record.uses_action",
+    }
+    assert {
+        item["property_uid"]
+        for item in first["element_mappings"]
+    } >= {"device.platform_uid", "device.source_code"}
+    assert profile.ready_to_publish is True
+    assert profile.missing_classes == ()
+    assert profile.missing_relations == ()
+    assert validate_graph(first) == []
+
+
+def test_device_profile_reports_missing_semantic_requirements():
+    from app.core.data_research.device_semantics import (
+        assess_device_graph,
+        build_device_graph,
+    )
+
+    graph = build_device_graph(DOMAIN_UID)
+    graph["classes"] = [
+        item for item in graph["classes"] if item["uid"] != "fault"
+    ]
+    graph["relations"] = [
+        item
+        for item in graph["relations"]
+        if item["uid"] != "alarm.classified_as"
+    ]
+    graph["element_mappings"] = [
+        item
+        for item in graph["element_mappings"]
+        if item["property_uid"] != "device.platform_uid"
+    ]
+
+    profile = assess_device_graph(graph)
+
+    assert profile.ready_to_publish is False
+    assert profile.missing_classes == ("fault",)
+    assert profile.missing_relations == ("alarm.classified_as",)
+    assert profile.missing_mappings == ("device.platform_uid",)
+
+
+def test_device_semantic_bootstrap_is_idempotent_and_keeps_one_draft():
+    from app.core.data_research.device_semantics import DeviceSemanticService
+
+    repository = memory_repository()
+    commits = []
+    service = DeviceSemanticService(
+        repository,
+        commit=lambda: commits.append(True),
+    )
+
+    first = service.bootstrap(
+        {"owner_domain_uid": DOMAIN_UID},
+        actor_uid=MANAGER_UID,
+    )
+    second = service.bootstrap(
+        {"owner_domain_uid": DOMAIN_UID},
+        actor_uid=MANAGER_UID,
+    )
+
+    assert first.created is True
+    assert second.created is False
+    assert second.ontology.uid == first.ontology.uid
+    assert second.version.uid == first.version.uid
+    assert second.profile.ready_to_publish is True
+    assert len(repository.list()) == 1
+    assert len(repository.list_versions(first.ontology.uid)) == 1
+    assert len(commits) == 1
+
+
+def test_device_publication_requires_complete_profile_and_accountable_manager():
+    from app.core.data_research.device_semantics import (
+        DeviceOntologyPublicationAuthorizer,
+        DeviceSemanticForbidden,
+        DeviceSemanticInvalid,
+        DeviceSemanticService,
+    )
+
+    repository = memory_repository()
+    result = DeviceSemanticService(repository).bootstrap(
+        {"owner_domain_uid": DOMAIN_UID},
+        actor_uid=MANAGER_UID,
+    )
+    authorizer = DeviceOntologyPublicationAuthorizer(
+        repository,
+        responsibility_lookup=responsibility_lookup(),
+    )
+
+    authorizer(result.version, MANAGER_UID)
+    with pytest.raises(DeviceSemanticForbidden, match="accountable"):
+        authorizer(result.version, OTHER_UID)
+
+    incomplete = replace(
+        result.version,
+        graph_document=result.version.graph_document.from_dict(
+            {
+                **result.version.graph_document.to_dict(),
+                "classes": [],
+            }
+        ),
+    )
+    with pytest.raises(DeviceSemanticInvalid, match="profile"):
+        authorizer(incomplete, MANAGER_UID)
+
+
+def test_non_device_ontology_does_not_require_device_responsibility():
+    from app.core.data_research.device_semantics import (
+        DeviceOntologyPublicationAuthorizer,
+    )
+    from tests.data_research.test_ontology_validation import base_graph
+
+    repository = memory_repository()
+    ontology = repository.create(
+        code="CUSTOMER",
+        name="客户本体",
+        owner_uid=MANAGER_UID,
+        domain_links=base_graph()["domain_links"],
+    )
+    version = repository.save_draft(
+        ontology.uid,
+        base_graph(),
+        expected_revision=0,
+        actor_uid=MANAGER_UID,
+    )
+
+    DeviceOntologyPublicationAuthorizer(
+        repository,
+        responsibility_lookup=lambda *_args: pytest.fail(
+            "generic ontology must not query device responsibility"
+        ),
+    )(version, OTHER_UID)
+
+
+class MemorySemanticCodeRepository:
+    def __init__(self, ontology_uid):
+        self.ontologies = {ontology_uid: "DEVICE_SEMANTIC"}
+        self.codes = {}
+        self.versions_by_code = {}
+        self.reviews_by_code = {}
+
+    def ontology_exists(self, ontology_uid):
+        return ontology_uid in self.ontologies
+
+    def is_device_ontology(self, ontology_uid):
+        return self.ontologies.get(ontology_uid) == "DEVICE_SEMANTIC"
+
+    def find_code(self, ontology_uid, code_type, canonical_code):
+        return next(
+            (
+                item
+                for item in self.codes.values()
+                if (
+                    item.ontology_uid,
+                    item.code_type,
+                    item.canonical_code,
+                )
+                == (ontology_uid, code_type, canonical_code)
+            ),
+            None,
+        )
+
+    def get_code(self, code_uid, *, for_update=False):
+        del for_update
+        return self.codes.get(code_uid)
+
+    def create_code(self, record, version):
+        if self.find_code(
+            record.ontology_uid,
+            record.code_type,
+            record.canonical_code,
+        ):
+            from app.core.data_research.errors import (
+                DeviceSemanticConflict,
+            )
+
+            raise DeviceSemanticConflict("semantic code already exists")
+        self.codes[record.uid] = record
+        self.versions_by_code[record.uid] = [version]
+        self.reviews_by_code[record.uid] = []
+        return record
+
+    def revise_code(self, record, version):
+        self.codes[record.uid] = record
+        self.versions_by_code[record.uid].append(version)
+        return record
+
+    def set_status(self, record, status, *, updated_at, updated_by):
+        updated = replace(
+            record,
+            status=status,
+            updated_at=updated_at,
+            updated_by=updated_by,
+        )
+        self.codes[record.uid] = updated
+        return updated
+
+    def review_code(self, record, review, status):
+        updated = replace(
+            record,
+            status=status,
+            updated_at=review.created_at,
+            updated_by=review.actor_uid,
+        )
+        self.codes[record.uid] = updated
+        self.reviews_by_code[record.uid].append(review)
+        return updated, review
+
+    def search(self, filters, *, page, page_size):
+        records = list(self.codes.values())
+        for name in ("ontology_uid", "code_type", "status"):
+            if filters.get(name):
+                records = [
+                    item
+                    for item in records
+                    if getattr(item, name) == filters[name]
+                ]
+        keyword = filters.get("keyword")
+        if keyword:
+            folded = keyword.casefold()
+            records = [
+                item
+                for item in records
+                if folded in item.canonical_code.casefold()
+                or folded in item.canonical_name.casefold()
+            ]
+        records.sort(key=lambda item: (item.code_type, item.canonical_code))
+        start = (page - 1) * page_size
+        return records[start : start + page_size], len(records)
+
+    def list_versions(self, code_uid):
+        return list(reversed(self.versions_by_code.get(code_uid, ())))
+
+    def list_reviews(self, code_uid):
+        return list(reversed(self.reviews_by_code.get(code_uid, ())))
+
+
+def code_service(repository=None, *, authorizer=None):
+    from app.core.data_research.device_semantics import (
+        DeviceSemanticCodeService,
+    )
+
+    ontology_uid = "01900000-0000-7000-8000-000000000521"
+    ids = iter(
+        (
+            "01900000-0000-7000-8000-000000000531",
+            "01900000-0000-7000-8000-000000000532",
+            "01900000-0000-7000-8000-000000000533",
+            "01900000-0000-7000-8000-000000000534",
+            "01900000-0000-7000-8000-000000000535",
+        )
+    )
+    clock = iter(
+        (
+            datetime(2026, 7, 29, 15, 0, tzinfo=timezone(timedelta(hours=8))),
+            datetime(2026, 7, 29, 15, 1, tzinfo=timezone(timedelta(hours=8))),
+            datetime(2026, 7, 29, 15, 2, tzinfo=timezone(timedelta(hours=8))),
+            datetime(2026, 7, 29, 15, 3, tzinfo=timezone(timedelta(hours=8))),
+        )
+    )
+    code_repository = repository or MemorySemanticCodeRepository(
+        ontology_uid
+    )
+    return (
+        DeviceSemanticCodeService(
+            code_repository,
+            uid_factory=ids.__next__,
+            now_factory=clock.__next__,
+            review_authorizer=authorizer or (
+                lambda _ontology_uid, _actor_uid: None
+            ),
+        ),
+        code_repository,
+        ontology_uid,
+    )
+
+
+@pytest.mark.parametrize("code_type", ("fault", "cause", "action"))
+def test_semantic_code_supports_three_governed_types_and_immutable_revision(
+    code_type,
+):
+    service, repository, ontology_uid = code_service()
+    created = service.create(
+        {
+            "ontology_uid": ontology_uid,
+            "code_type": code_type,
+            "canonical_code": f"{code_type}-001",
+            "canonical_name": f"{code_type}示例",
+            "definition": "首版定义",
+            "source_mappings": [
+                {
+                    "source_uid": SOURCE_UID,
+                    "source_code": "SRC-001",
+                }
+            ],
+        },
+        actor_uid=MANAGER_UID,
+    )
+    revised = service.revise(
+        created.uid,
+        {
+            "canonical_name": f"{code_type}统一名称",
+            "definition": "修订定义",
+            "source_mappings": list(created.source_mappings),
+        },
+        expected_version=1,
+        actor_uid=MANAGER_UID,
+    )
+
+    assert created.canonical_code == f"{code_type.upper()}-001"
+    assert revised.uid == created.uid
+    assert revised.current_version == 2
+    versions = repository.list_versions(created.uid)
+    assert [item.version for item in versions] == [2, 1]
+    assert versions[0].snapshot["canonical_name"] == f"{code_type}统一名称"
+    assert versions[1].snapshot["canonical_name"] == f"{code_type}示例"
+
+
+def test_semantic_code_rejects_secrets_and_unproven_suggestions():
+    from app.core.data_research.errors import DeviceSemanticInvalid
+
+    service, _repository, ontology_uid = code_service()
+    base = {
+        "ontology_uid": ontology_uid,
+        "code_type": "fault",
+        "canonical_code": "F-001",
+        "canonical_name": "轴承过热",
+    }
+
+    with pytest.raises(DeviceSemanticInvalid, match="secret"):
+        service.create(
+            {
+                **base,
+                "source_mappings": [
+                    {
+                        "source_uid": SOURCE_UID,
+                        "source_code": "SRC-001",
+                        "token": "secret",
+                    }
+                ],
+            },
+            actor_uid=MANAGER_UID,
+        )
+    with pytest.raises(DeviceSemanticInvalid, match="secret"):
+        service.create(
+            {
+                **base,
+                "contact_phone": "13800000000",
+            },
+            actor_uid=MANAGER_UID,
+        )
+    with pytest.raises(DeviceSemanticInvalid, match="evidence"):
+        service.create(
+            {**base, "suggestion_source": "rule"},
+            actor_uid=MANAGER_UID,
+        )
+    with pytest.raises(DeviceSemanticInvalid, match="confidence"):
+        service.create(
+            {
+                **base,
+                "suggestion_source": "ai",
+                "evidence_uids": ["evidence-1"],
+                "confidence": 1.2,
+            },
+            actor_uid=MANAGER_UID,
+        )
+
+
+def test_semantic_codes_cannot_be_attached_to_a_generic_ontology():
+    from app.core.data_research.errors import DeviceSemanticInvalid
+
+    service, repository, _ontology_uid = code_service()
+    generic_uid = "01900000-0000-7000-8000-000000000522"
+    repository.ontologies[generic_uid] = "CUSTOMER"
+
+    with pytest.raises(DeviceSemanticInvalid, match="device ontology"):
+        service.create(
+            {
+                "ontology_uid": generic_uid,
+                "code_type": "fault",
+                "canonical_code": "F-OTHER-001",
+                "canonical_name": "非设备故障",
+            },
+            actor_uid=MANAGER_UID,
+        )
+def test_semantic_code_submit_and_review_are_stateful_and_audited():
+    approvals = []
+    service, repository, ontology_uid = code_service(
+        authorizer=lambda ontology, actor: approvals.append(
+            (ontology, actor)
+        )
+    )
+    record = service.create(
+        {
+            "ontology_uid": ontology_uid,
+            "code_type": "fault",
+            "canonical_code": "F-001",
+            "canonical_name": "轴承过热",
+        },
+        actor_uid=OTHER_UID,
+    )
+    submitted = service.submit(
+        record.uid,
+        expected_version=1,
+        actor_uid=OTHER_UID,
+    )
+    reviewed, review = service.review(
+        record.uid,
+        {
+            "decision": "approve",
+            "reason": "与企业故障字典一致",
+            "expected_version": 1,
+        },
+        actor_uid=MANAGER_UID,
+    )
+
+    assert submitted.status == "in_review"
+    assert reviewed.status == "published"
+    assert review.version == 1
+    assert review.decision == "approve"
+    assert review.actor_uid == MANAGER_UID
+    assert approvals == [(ontology_uid, MANAGER_UID)]
+    assert repository.list_reviews(record.uid) == [review]
+
+
+def test_semantic_code_rejects_stale_revision_duplicate_and_bad_transition():
+    from app.core.data_research.errors import (
+        DeviceSemanticConflict,
+        DeviceSemanticInvalid,
+    )
+
+    service, _repository, ontology_uid = code_service()
+    payload = {
+        "ontology_uid": ontology_uid,
+        "code_type": "cause",
+        "canonical_code": "C-001",
+        "canonical_name": "润滑不足",
+    }
+    record = service.create(payload, actor_uid=MANAGER_UID)
+
+    with pytest.raises(DeviceSemanticConflict, match="exists"):
+        service.create(payload, actor_uid=MANAGER_UID)
+    with pytest.raises(DeviceSemanticConflict, match="version"):
+        service.revise(
+            record.uid,
+            {"canonical_name": "润滑油不足"},
+            expected_version=0,
+            actor_uid=MANAGER_UID,
+        )
+    with pytest.raises(DeviceSemanticInvalid, match="in_review"):
+        service.review(
+            record.uid,
+            {
+                "decision": "approve",
+                "reason": "尚未提交",
+                "expected_version": 1,
+            },
+            actor_uid=MANAGER_UID,
+        )
+
+
+def test_semantic_code_search_and_traceability_are_bounded():
+    service, _repository, ontology_uid = code_service()
+    fault = service.create(
+        {
+            "ontology_uid": ontology_uid,
+            "code_type": "fault",
+            "canonical_code": "F-001",
+            "canonical_name": "轴承过热",
+        },
+        actor_uid=MANAGER_UID,
+    )
+    service.create(
+        {
+            "ontology_uid": ontology_uid,
+            "code_type": "cause",
+            "canonical_code": "C-001",
+            "canonical_name": "润滑不足",
+        },
+        actor_uid=MANAGER_UID,
+    )
+
+    records, total = service.search(
+        {
+            "ontology_uid": ontology_uid,
+            "code_type": "fault",
+            "keyword": "过热",
+        },
+        page=1,
+        page_size=20,
+    )
+
+    assert total == 1
+    assert records == [fault]
+    assert service.get(fault.uid) == fault
+    assert service.versions(fault.uid)[0].snapshot["canonical_code"] == "F-001"

+ 24 - 0
tests/data_research/test_ontology_publication.py

@@ -104,3 +104,27 @@ def test_version_diff_and_rollback_create_new_draft():
     assert rollback.version == 2
     assert rollback.parent_version_uid == version.uid
     assert rollback.status == "draft"
+
+
+def test_publication_runs_optional_domain_authorizer_before_activation():
+    from app.core.data_research.ontology.publication import (
+        OntologyPublicationService,
+    )
+
+    repo, ontology, version = repository()
+    authorized = []
+    service = OntologyPublicationService(
+        repo,
+        publication_authorizer=lambda candidate, actor: authorized.append(
+            (candidate.uid, actor)
+        ),
+    )
+
+    service.publish(
+        version.uid,
+        idempotency_key="publish-authorized",
+        actor_uid="manager-1",
+    )
+
+    assert authorized == [(version.uid, "manager-1")]
+    assert repo.get(ontology.uid).active_version_uid == version.uid

+ 152 - 0
tests/integration/test_device_semantics_postgres.py

@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+import os
+import uuid
+
+import pytest
+
+pytestmark = pytest.mark.integration
+
+
+def test_device_semantic_codes_keep_versions_reviews_and_search(
+    monkeypatch,
+):
+    platform_url = os.environ.get("TEST_DATABASE_URL")
+    if not platform_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+
+    monkeypatch.setenv("DATABASE_URL", platform_url)
+    from app import create_app, db
+    from app.core.data_research.device_semantic_repository import (
+        SqlAlchemyDeviceSemanticCodeRepository,
+    )
+    from app.core.data_research.device_semantics import (
+        DeviceSemanticCodeService,
+    )
+    from app.models.data_research import (
+        DeviceSemanticCode,
+        DeviceSemanticCodeReview,
+        DeviceSemanticCodeVersion,
+        OntologyModel,
+    )
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    ontology_uid = None
+    created_ontology = False
+    canonical_code = f"F-PG-{uuid.uuid4().hex[:8].upper()}"
+    code_uid = None
+    try:
+        with app.app_context():
+            existing = OntologyModel.query.filter_by(
+                code="DEVICE_SEMANTIC"
+            ).first()
+            if existing is None:
+                ontology_uid = str(uuid.uuid4())
+                db.session.add(
+                    OntologyModel(
+                        uid=ontology_uid,
+                        code="DEVICE_SEMANTIC",
+                        name="设备语义集成验收本体",
+                        owner_uid="integration-test",
+                        status="draft",
+                        draft_revision=0,
+                        created_by="integration-test",
+                    )
+                )
+                created_ontology = True
+            else:
+                ontology_uid = str(existing.uid)
+            db.session.commit()
+
+            repository = SqlAlchemyDeviceSemanticCodeRepository(
+                db.session
+            )
+            approvals = []
+            service = DeviceSemanticCodeService(
+                repository,
+                review_authorizer=lambda ontology, actor: approvals.append(
+                    (ontology, actor)
+                ),
+                commit=db.session.commit,
+                rollback=db.session.rollback,
+            )
+            created = service.create(
+                {
+                    "ontology_uid": ontology_uid,
+                    "code_type": "fault",
+                    "canonical_code": canonical_code,
+                    "canonical_name": "轴承过热",
+                    "definition": "集成验收首版定义",
+                    "suggestion_source": "rule",
+                    "evidence_uids": ["integration-evidence-1"],
+                },
+                actor_uid="integration-editor",
+            )
+            code_uid = created.uid
+            revised = service.revise(
+                created.uid,
+                {
+                    "canonical_name": "轴承温度过高",
+                    "definition": "集成验收修订定义",
+                },
+                expected_version=1,
+                actor_uid="integration-editor",
+            )
+            service.submit(
+                created.uid,
+                expected_version=2,
+                actor_uid="integration-editor",
+            )
+            published, review = service.review(
+                created.uid,
+                {
+                    "decision": "approve",
+                    "reason": "企业故障字典验收通过",
+                    "expected_version": 2,
+                },
+                actor_uid="integration-manager",
+            )
+
+            records, total = service.search(
+                {
+                    "ontology_uid": ontology_uid,
+                    "code_type": "fault",
+                    "status": "published",
+                    "keyword": canonical_code,
+                },
+                page=1,
+                page_size=20,
+            )
+            versions = service.versions(created.uid)
+            reviews = service.reviews(created.uid)
+
+            assert revised.current_version == 2
+            assert published.status == "published"
+            assert review.version == 2
+            assert total == 1
+            assert records[0].uid == created.uid
+            assert [item.version for item in versions] == [2, 1]
+            assert versions[0].snapshot["canonical_name"] == "轴承温度过高"
+            assert versions[1].snapshot["canonical_name"] == "轴承过热"
+            assert reviews == [review]
+            assert approvals == [
+                (ontology_uid, "integration-manager")
+            ]
+    finally:
+        with app.app_context():
+            if code_uid:
+                db.session.query(DeviceSemanticCodeReview).filter_by(
+                    code_uid=code_uid
+                ).delete(synchronize_session=False)
+                db.session.query(DeviceSemanticCodeVersion).filter_by(
+                    code_uid=code_uid
+                ).delete(synchronize_session=False)
+                db.session.query(DeviceSemanticCode).filter_by(
+                    uid=code_uid
+                ).delete(synchronize_session=False)
+            if created_ontology:
+                db.session.query(OntologyModel).filter_by(
+                    uid=ontology_uid
+                ).delete(synchronize_session=False)
+            db.session.commit()

+ 27 - 0
tests/test_database_migrations.py

@@ -55,6 +55,9 @@ EXPECTED_UPGRADED_TABLES = {
     "device_assets",
     "device_asset_source_mappings",
     "device_asset_versions",
+    "device_semantic_codes",
+    "device_semantic_code_versions",
+    "device_semantic_code_reviews",
 }
 
 
@@ -125,6 +128,30 @@ def test_device_asset_catalog_migration_is_versioned_and_traceable():
     assert "DROP TABLE" not in migration.upper()
 
 
+def test_device_semantics_migration_is_versioned_reviewed_and_traceable():
+    migration = (
+        ROOT
+        / "migrations"
+        / "versions"
+        / "20260729_300_device_semantics.py"
+    ).read_text(encoding="utf-8")
+
+    assert 'revision = "20260729_300"' in migration
+    assert 'down_revision = "20260729_290"' in migration
+    for table in (
+        "device_semantic_codes",
+        "device_semantic_code_versions",
+        "device_semantic_code_reviews",
+    ):
+        assert f"CREATE TABLE public.{table}" in migration
+    assert "UNIQUE (ontology_uid, code_type, canonical_code)" in migration
+    assert "UNIQUE (code_uid, version)" in migration
+    assert "source_mappings JSONB NOT NULL" in migration
+    assert "evidence_uids JSONB NOT NULL" in migration
+    assert "decision IN ('approve','reject')" in migration
+    assert "DROP TABLE" not in migration.upper()
+
+
 def test_data_element_migration_adds_versioned_governance_tables():
     migration = (
         ROOT

+ 17 - 0
tests/test_permission_matrix.py

@@ -20,6 +20,9 @@ def test_fixed_role_permission_matrix_is_monotonic():
     assert "ontologies:publish" in admin
     assert "governance:responsibilities:manage" in admin
     assert "device-assets:edit" in editor
+    assert "device-semantics:edit" in editor
+    assert "device-semantics:review" not in editor
+    assert "device-semantics:review" in admin
 
 
 def test_data_development_paths_have_specific_write_policies():
@@ -61,6 +64,20 @@ def test_data_development_paths_have_specific_write_policies():
     assert permission_for_request(
         "/api/development/v1/device-assets/import", "POST"
     ) == ("device-assets:edit",)
+    assert permission_for_request(
+        "/api/development/v1/device-semantics/profile", "GET"
+    ) == ("governance:read",)
+    assert permission_for_request(
+        "/api/development/v1/device-semantics/bootstrap", "POST"
+    ) == ("device-semantics:edit",)
+    assert permission_for_request(
+        "/api/development/v1/device-semantics/codes/code-1/submit",
+        "POST",
+    ) == ("device-semantics:edit",)
+    assert permission_for_request(
+        "/api/development/v1/device-semantics/codes/code-1/review",
+        "POST",
+    ) == ("device-semantics:review",)
 
 
 def test_business_domain_read_endpoints_are_available_to_viewers():