Browse Source

feat: add tamper-evident governance audit

马小龙 3 weeks ago
parent
commit
ec05999815
42 changed files with 4940 additions and 25 deletions
  1. 38 8
      app/__init__.py
  2. 4 1
      app/api/system/__init__.py
  3. 299 0
      app/api/system/governance_audit.py
  4. 6 0
      app/config/config.py
  5. 5 1
      app/core/data_source/redaction.py
  6. 328 0
      app/core/system/governance_audit.py
  7. 357 0
      app/core/system/governance_audit_repository.py
  8. 8 0
      app/core/system/permissions.py
  9. 5 0
      deploy/docker/.env.example
  10. 2 0
      deploy/docker/docker-compose.yml
  11. 4 0
      deployment/.env.production.example
  12. 42 8
      deployment/app/__init__.py
  13. 4 0
      deployment/app/api/system/__init__.py
  14. 299 0
      deployment/app/api/system/governance_audit.py
  15. 6 0
      deployment/app/config/config.py
  16. 5 1
      deployment/app/core/data_source/redaction.py
  17. 328 0
      deployment/app/core/system/governance_audit.py
  18. 357 0
      deployment/app/core/system/governance_audit_repository.py
  19. 8 0
      deployment/app/core/system/permissions.py
  20. 4 0
      deployment/dataops.env
  21. 40 0
      deployment/migrations/versions/20260730_360_governance_audit_seals.py
  22. 1 0
      docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md
  23. 5 5
      docs/FUNCTION_MODULE_CENSUS_20260726.md
  24. 33 0
      docs/architecture/DATA_MODEL.md
  25. 134 1
      docs/architecture/OPENAPI.yaml
  26. 207 0
      docs/superpowers/plans/2026-07-30-wp12-security-audit-evidence.md
  27. 100 0
      docs/validation/WP12_SECURITY_AUDIT_EVIDENCE.md
  28. 28 0
      frontend/src/api/governanceAudit.js
  29. 17 0
      frontend/src/router/routes.js
  30. 148 0
      frontend/src/views/systemManage/governanceAudit/governanceAuditModel.js
  31. 586 0
      frontend/src/views/systemManage/governanceAudit/index.vue
  32. 99 0
      frontend/tests/governance-audit-model.test.mjs
  33. 40 0
      migrations/versions/20260730_360_governance_audit_seals.py
  34. 487 0
      tests/integration/test_governance_audit_postgres.py
  35. 274 0
      tests/system/test_governance_audit.py
  36. 242 0
      tests/system/test_governance_audit_api.py
  37. 60 0
      tests/system/test_governance_audit_frontend_contract.py
  38. 187 0
      tests/system/test_governance_audit_repository.py
  39. 67 0
      tests/system/test_safe_error_boundary.py
  40. 45 0
      tests/test_architecture_artifacts.py
  41. 20 0
      tests/test_database_migrations.py
  42. 11 0
      tests/test_permission_matrix.py

+ 38 - 8
app/__init__.py

@@ -1,5 +1,6 @@
 import logging
 import os
+import uuid
 
 from flask import Flask, jsonify
 from flask_cors import CORS
@@ -32,13 +33,13 @@ def create_app():
 
     # 注册蓝图
     from app.api.business_domain import bp as business_domain_bp
+    from app.api.data_development import bp as data_development_bp
     from app.api.data_factory import bp as data_factory_bp
     from app.api.data_flow import bp as data_flow_bp
     from app.api.data_interface import bp as data_interface_bp
     from app.api.data_rules import bp as data_rules_bp
     from app.api.data_service import bp as data_service_bp
     from app.api.data_source import bp as data_source_bp
-    from app.api.data_development import bp as data_development_bp
     from app.api.graph import bp as graph_bp
     from app.api.knowledge_base import bp as knowledge_base_bp
     from app.api.meta_data import bp as meta_bp
@@ -169,6 +170,19 @@ def configure_response_headers(app):
                 response.headers["X-Frame-Options"] = "DENY"
             if "X-XSS-Protection" not in response.headers:
                 response.headers["X-XSS-Protection"] = "1; mode=block"
+            if "Referrer-Policy" not in response.headers:
+                response.headers["Referrer-Policy"] = "no-referrer"
+            if "Permissions-Policy" not in response.headers:
+                response.headers["Permissions-Policy"] = (
+                    "camera=(), microphone=(), geolocation=()"
+                )
+            if (
+                request.path.startswith("/api/system/auth")
+                or request.path.startswith(
+                    "/api/system/governance-audit"
+                )
+            ):
+                response.headers["Cache-Control"] = "no-store"
 
         if request.path.startswith("/api/") and request.path != "/api/system/health":
             app.logger.info(
@@ -241,14 +255,20 @@ def configure_error_handlers(app):
     @app.errorhandler(Exception)
     def handle_exception(e):
         """全局异常处理器,捕获所有未处理的异常"""
-        # 记录详细的错误信息
-        app.logger.error(f"未处理的异常: {str(e)}", exc_info=True)
-
-        # 返回标准化的错误响应
+        from app.core.data_source.redaction import sanitize_exception
+
+        correlation_id = str(uuid.uuid4())
+        app.logger.error(
+            "未处理的异常 correlation_id=%s type=%s detail=%s",
+            correlation_id,
+            type(e).__name__,
+            sanitize_exception(e, limit=300),
+        )
         error_response = {
             "success": False,
-            "message": f"服务器内部错误: {str(e)}",
+            "message": "服务器内部错误",
             "data": None,
+            "correlation_id": correlation_id,
         }
 
         return jsonify(error_response), 500
@@ -264,7 +284,17 @@ def configure_error_handlers(app):
     @app.errorhandler(500)
     def handle_internal_error(e):
         """处理500错误"""
-        app.logger.error(f"500错误: {str(e)}", exc_info=True)
+        correlation_id = str(uuid.uuid4())
+        app.logger.error(
+            "500错误 correlation_id=%s type=%s",
+            correlation_id,
+            type(e).__name__,
+        )
         return jsonify(
-            {"success": False, "message": "服务器内部错误", "data": None}
+            {
+                "success": False,
+                "message": "服务器内部错误",
+                "data": None,
+                "correlation_id": correlation_id,
+            }
         ), 500

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

@@ -1,8 +1,11 @@
+# ruff: noqa: E402, F401, I001
+
 from flask import Blueprint
 
 bp = Blueprint("system", __name__)
 
-from app.api.system import routes  # noqa: E402, F401
+from app.api.system import governance_audit  # noqa: E402, F401
 from app.api.system import responsibilities  # noqa: E402, F401
+from app.api.system import routes  # noqa: E402, F401
 from app.api.system import users  # noqa: E402, F401
 from app.api.system import workbench  # noqa: E402, F401

+ 299 - 0
app/api/system/governance_audit.py

@@ -0,0 +1,299 @@
+"""Admin-only governance audit and tamper-evident runtime evidence APIs."""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+from datetime import UTC, datetime, timedelta
+
+from flask import current_app, g, jsonify, request
+
+from app import db
+from app.api.system import bp
+from app.config.config import is_placeholder_env_value
+from app.core.data_source.redaction import sanitize_exception
+from app.core.system.governance_audit import (
+    GovernanceAuditInvalid,
+    GovernanceAuditNotFound,
+    GovernanceAuditService,
+)
+from app.core.system.governance_audit_repository import (
+    SqlAlchemyGovernanceAuditRepository,
+)
+from app.models.result import failed, success
+
+logger = logging.getLogger(__name__)
+
+
+class GovernanceAuditUnavailable(RuntimeError):
+    """Raised when the server cannot safely perform a sealing operation."""
+
+
+def _effective_evidence_secret() -> tuple[str, bool]:
+    dedicated = str(
+        current_app.config.get("AUDIT_EVIDENCE_SECRET") or ""
+    ).strip()
+    dedicated_ready = (
+        len(dedicated.encode("utf-8")) >= 32
+        and not is_placeholder_env_value(dedicated)
+    )
+    if dedicated_ready:
+        return dedicated, True
+    fallback = hashlib.sha256(
+        (
+            "dataops-wp12-local-audit:"
+            + str(current_app.config.get("SECRET_KEY") or "")
+        ).encode("utf-8")
+    ).hexdigest()
+    return fallback, False
+
+
+def _sealing_allowed(dedicated_ready: bool) -> bool:
+    if dedicated_ready:
+        return True
+    if current_app.config.get("TESTING"):
+        return True
+    return (
+        str(current_app.config.get("FLASK_ENV") or "").lower()
+        != "production"
+    )
+
+
+def _require_sealing_key() -> None:
+    _secret, dedicated_ready = _effective_evidence_secret()
+    if not _sealing_allowed(dedicated_ready):
+        raise GovernanceAuditUnavailable(
+            "dedicated audit evidence key is required"
+        )
+
+
+def get_governance_audit_service():
+    secret, _dedicated = _effective_evidence_secret()
+    return GovernanceAuditService(
+        SqlAlchemyGovernanceAuditRepository(db.session),
+        evidence_secret=secret,
+        key_version=str(
+            current_app.config.get("AUDIT_EVIDENCE_KEY_VERSION")
+            or "local-fallback-v1"
+        ),
+    )
+
+
+def get_security_checks():
+    repository = SqlAlchemyGovernanceAuditRepository(db.session)
+    snapshot = repository.security_snapshot()
+    _secret, dedicated_ready = _effective_evidence_secret()
+    credential_count = snapshot["credential_count"]
+    encrypted_count = snapshot["encrypted_credential_count"]
+    plaintext_count = snapshot["plaintext_source_config_count"]
+    return {
+        "seal_ready": _sealing_allowed(dedicated_ready),
+        "production_key_ready": dedicated_ready,
+        "assurance": (
+            "签名封存用于检测篡改,不等于阻止数据库管理员修改;"
+            "生产环境必须使用独立密钥并纳入企业密钥保管与轮换。"
+        ),
+        "checks": [
+            {
+                "code": "datasource_credential_encryption",
+                "name": "数据源凭据密文存储",
+                "status": (
+                    "passed"
+                    if credential_count == encrypted_count
+                    else "failed"
+                ),
+                "observed_count": encrypted_count,
+                "expected_count": credential_count,
+            },
+            {
+                "code": "legacy_plaintext_cleanup",
+                "name": "采集源配置无明文秘密字段",
+                "status": "passed" if plaintext_count == 0 else "failed",
+                "observed_count": plaintext_count,
+                "expected_count": 0,
+            },
+            {
+                "code": "dedicated_evidence_key",
+                "name": "独立审计封存密钥",
+                "status": "passed" if dedicated_ready else "warning",
+                "reason_code": (
+                    "dedicated_key_configured"
+                    if dedicated_ready
+                    else "local_fallback_key_in_use"
+                ),
+            },
+            {
+                "code": "safe_error_boundary",
+                "name": "异常与日志脱敏边界",
+                "status": "passed",
+            },
+            {
+                "code": "security_response_headers",
+                "name": "API 安全响应头",
+                "status": "passed",
+            },
+            {
+                "code": "audit_source_coverage",
+                "name": "六类关键操作审计源",
+                "status": "passed",
+                "expected_count": 6,
+            },
+        ],
+    }
+
+
+def _timestamp(value, *, default=None):
+    if value in (None, ""):
+        if default is None:
+            raise GovernanceAuditInvalid("timestamp is required")
+        return default
+    try:
+        parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+    except (TypeError, ValueError) as exc:
+        raise GovernanceAuditInvalid("timestamp must be ISO-8601") from exc
+    if parsed.tzinfo is None:
+        raise GovernanceAuditInvalid("timestamp must include a timezone")
+    return parsed.astimezone(UTC)
+
+
+def _window(values):
+    now = datetime.now(UTC)
+    return (
+        _timestamp(
+            values.get("period_start"),
+            default=now - timedelta(days=30),
+        ),
+        _timestamp(values.get("period_end"), default=now),
+    )
+
+
+def _categories(values):
+    if hasattr(values, "getlist"):
+        result = values.getlist("category")
+        if result:
+            return result
+    return values.get("categories") or values.get("category")
+
+
+def _safe_error(error, status=503):
+    logger.error(
+        "governance audit request failed: %s",
+        sanitize_exception(error, limit=300),
+    )
+    return (
+        jsonify(
+            failed(
+                "审计与运行证据暂不可用",
+                code=status,
+                error={"code": "GOVERNANCE_AUDIT_UNAVAILABLE"},
+            )
+        ),
+        status,
+    )
+
+
+def _execute(operation):
+    try:
+        return jsonify(success(operation()))
+    except GovernanceAuditInvalid as exc:
+        return (
+            jsonify(
+                failed(
+                    str(exc),
+                    code=400,
+                    error={"code": "GOVERNANCE_AUDIT_INVALID"},
+                )
+            ),
+            400,
+        )
+    except GovernanceAuditNotFound:
+        return (
+            jsonify(
+                failed(
+                    "审计证据封存不存在",
+                    code=404,
+                    error={"code": "GOVERNANCE_AUDIT_NOT_FOUND"},
+                )
+            ),
+            404,
+        )
+    except GovernanceAuditUnavailable:
+        return _safe_error(
+            GovernanceAuditUnavailable("audit sealing is not configured")
+        )
+    except Exception as exc:
+        db.session.rollback()
+        return _safe_error(exc)
+
+
+@bp.route("/governance-audit/security-checks", methods=["GET"])
+def governance_audit_security_checks():
+    return _execute(get_security_checks)
+
+
+@bp.route("/governance-audit/coverage", methods=["GET"])
+def governance_audit_coverage():
+    def operation():
+        start, end = _window(request.args)
+        return get_governance_audit_service().coverage(
+            period_start=start,
+            period_end=end,
+        )
+
+    return _execute(operation)
+
+
+@bp.route("/governance-audit/events", methods=["GET"])
+def governance_audit_events():
+    def operation():
+        start, end = _window(request.args)
+        return get_governance_audit_service().list_events(
+            period_start=start,
+            period_end=end,
+            categories=_categories(request.args),
+            action=request.args.get("action"),
+            status=request.args.get("status"),
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+
+    return _execute(operation)
+
+
+@bp.route("/governance-audit/seals", methods=["GET"])
+def governance_audit_seals():
+    return _execute(
+        lambda: get_governance_audit_service().list_seals(
+            limit=request.args.get("limit", 50)
+        )
+    )
+
+
+@bp.route("/governance-audit/seals", methods=["POST"])
+def create_governance_audit_seal():
+    def operation():
+        _require_sealing_key()
+        payload = request.get_json(silent=True) or {}
+        start, end = _window(payload)
+        seal = get_governance_audit_service().create_seal(
+            period_start=start,
+            period_end=end,
+            categories=_categories(payload),
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return seal
+
+    return _execute(operation)
+
+
+@bp.route(
+    "/governance-audit/seals/<seal_uid>/verify",
+    methods=["POST"],
+)
+def verify_governance_audit_seal(seal_uid):
+    def operation():
+        _require_sealing_key()
+        return get_governance_audit_service().verify_seal(seal_uid)
+
+    return _execute(operation)

+ 6 - 0
app/config/config.py

@@ -279,6 +279,12 @@ def apply_runtime_env_config(app) -> None:
                 "DATASOURCE_CERT_DIR",
                 "/etc/dataops-platform/datasource-certs",
             ),
+            "AUDIT_EVIDENCE_SECRET": _clean_env(
+                "AUDIT_EVIDENCE_SECRET"
+            ),
+            "AUDIT_EVIDENCE_KEY_VERSION": _clean_env(
+                "AUDIT_EVIDENCE_KEY_VERSION", "local-fallback-v1"
+            ),
         }
     )
 

+ 5 - 1
app/core/data_source/redaction.py

@@ -4,7 +4,6 @@ import re
 from collections.abc import Mapping
 from urllib.parse import urlsplit, urlunsplit
 
-
 REDACTED = "[redacted]"
 SENSITIVE_KEYS = {
     "password",
@@ -31,6 +30,7 @@ _NAMED_SECRET = re.compile(
     r"(?i)(password|passwd|api[_-]?key|token|authorization)"
     r"(\s*[=:]\s*)([^\s,;]+)"
 )
+_BEARER_SECRET = re.compile(r"(?i)(bearer)(\s+)([^\s,;]+)")
 
 
 def _redact_url(value: str) -> str:
@@ -102,4 +102,8 @@ def sanitize_exception(error: BaseException, limit: int = 1000) -> str:
         lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}",
         value,
     )
+    value = _BEARER_SECRET.sub(
+        lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}",
+        value,
+    )
     return value[: max(0, int(limit))]

+ 328 - 0
app/core/system/governance_audit.py

@@ -0,0 +1,328 @@
+"""Governance audit normalization and tamper-evident evidence sealing."""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+from datetime import UTC, datetime
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_source.redaction import redact_mapping
+
+AUDIT_CATEGORIES = (
+    "authentication",
+    "ingestion",
+    "entity_resolution",
+    "publication",
+    "remediation",
+    "knowledge_query",
+)
+MAX_SEAL_EVENTS = 50_000
+
+
+class GovernanceAuditInvalid(ValueError):
+    """Raised when an audit query or seal request is invalid."""
+
+
+class GovernanceAuditNotFound(LookupError):
+    """Raised when a requested evidence seal does not exist."""
+
+
+def _utc(value: datetime, label: str) -> datetime:
+    if not isinstance(value, datetime):
+        raise GovernanceAuditInvalid(f"{label} must be a datetime")
+    if value.tzinfo is None:
+        raise GovernanceAuditInvalid(f"{label} must include a timezone")
+    return value.astimezone(UTC)
+
+
+def _iso(value: datetime, label: str) -> str:
+    return _utc(value, label).isoformat().replace("+00:00", "Z")
+
+
+def _canonical(value: Any) -> bytes:
+    return json.dumps(
+        value,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    ).encode("utf-8")
+
+
+def _digest(value: Any) -> str:
+    return hashlib.sha256(_canonical(value)).hexdigest()
+
+
+def _required_text(value: Any, label: str, maximum: int) -> str:
+    normalized = str(value or "").strip()
+    if not normalized:
+        raise GovernanceAuditInvalid(f"{label} is required")
+    if len(normalized) > maximum:
+        raise GovernanceAuditInvalid(f"{label} exceeds {maximum} characters")
+    return normalized
+
+
+def _normalize_categories(categories=None) -> tuple[str, ...]:
+    if categories is None:
+        return AUDIT_CATEGORIES
+    if isinstance(categories, str):
+        categories = [categories]
+    normalized = tuple(
+        dict.fromkeys(str(item).strip() for item in categories if str(item).strip())
+    )
+    if not normalized:
+        raise GovernanceAuditInvalid("at least one category is required")
+    unsupported = sorted(set(normalized) - set(AUDIT_CATEGORIES))
+    if unsupported:
+        raise GovernanceAuditInvalid(
+            f"unsupported category: {', '.join(unsupported)}"
+        )
+    return tuple(item for item in AUDIT_CATEGORIES if item in normalized)
+
+
+def _normalize_window(period_start, period_end) -> tuple[datetime, datetime]:
+    start = _utc(period_start, "period_start")
+    end = _utc(period_end, "period_end")
+    if end <= start:
+        raise GovernanceAuditInvalid("period_end must be after period_start")
+    return start, end
+
+
+def _normalize_event(event: dict[str, Any]) -> dict[str, Any]:
+    category = _required_text(event.get("category"), "category", 40)
+    if category not in AUDIT_CATEGORIES:
+        raise GovernanceAuditInvalid(f"unsupported category: {category}")
+    occurred_at = event.get("occurred_at")
+    return {
+        "event_uid": _required_text(event.get("event_uid"), "event_uid", 160),
+        "category": category,
+        "action": _required_text(event.get("action"), "action", 80),
+        "status": _required_text(event.get("status"), "status", 40),
+        "actor_uid": str(event.get("actor_uid") or "")[:160] or None,
+        "resource_type": _required_text(
+            event.get("resource_type"), "resource_type", 80
+        ),
+        "resource_uid": str(event.get("resource_uid") or "")[:200] or None,
+        "occurred_at": _iso(occurred_at, "occurred_at"),
+        "safe_detail": redact_mapping(dict(event.get("safe_detail") or {})),
+    }
+
+
+def _event_sort_key(event: dict[str, Any]) -> tuple[str, str]:
+    return event["occurred_at"], event["event_uid"]
+
+
+def _root(events: list[dict[str, Any]]) -> str:
+    ordered = sorted(events, key=_event_sort_key)
+    return _digest([_digest(event) for event in ordered])
+
+
+def _signature_payload(seal: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "uid": seal["uid"],
+        "period_start": seal["period_start"],
+        "period_end": seal["period_end"],
+        "categories": list(seal["categories"]),
+        "event_count": int(seal["event_count"]),
+        "root_hash": seal["root_hash"],
+        "key_version": seal["key_version"],
+        "sealed_by": seal["sealed_by"],
+    }
+
+
+class GovernanceAuditService:
+    """Normalize distributed audit evidence and create signed evidence seals."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        evidence_secret: str,
+        key_version: str,
+        uid_factory=new_governance_uid,
+        now_factory=None,
+    ):
+        secret = str(evidence_secret or "").encode("utf-8")
+        if len(secret) < 32:
+            raise GovernanceAuditInvalid(
+                "audit evidence secret must contain at least 32 bytes"
+            )
+        self.repository = repository
+        self._secret = secret
+        self.key_version = _required_text(key_version, "key_version", 80)
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory or (lambda: datetime.now(UTC))
+
+    def _events(self, *, categories, period_start, period_end):
+        normalized_categories = _normalize_categories(categories)
+        start, end = _normalize_window(period_start, period_end)
+        events = [
+            _normalize_event(event)
+            for event in self.repository.fetch_events(
+                categories=normalized_categories,
+                period_start=start,
+                period_end=end,
+            )
+        ]
+        return normalized_categories, start, end, events
+
+    def list_events(
+        self,
+        *,
+        period_start,
+        period_end,
+        categories=None,
+        action=None,
+        status=None,
+        page=1,
+        page_size=20,
+    ) -> dict[str, Any]:
+        try:
+            page = int(page)
+            page_size = int(page_size)
+        except (TypeError, ValueError) as exc:
+            raise GovernanceAuditInvalid(
+                "page and page_size must be integers"
+            ) from exc
+        if page < 1:
+            raise GovernanceAuditInvalid("page must be at least 1")
+        if page_size < 1 or page_size > 100:
+            raise GovernanceAuditInvalid("page_size must be between 1 and 100")
+
+        selected, start, end, events = self._events(
+            categories=categories,
+            period_start=period_start,
+            period_end=period_end,
+        )
+        if action:
+            events = [event for event in events if event["action"] == action]
+        if status:
+            events = [event for event in events if event["status"] == status]
+        events.sort(key=_event_sort_key, reverse=True)
+        offset = (page - 1) * page_size
+        return {
+            "period_start": _iso(start, "period_start"),
+            "period_end": _iso(end, "period_end"),
+            "categories": list(selected),
+            "records": events[offset : offset + page_size],
+            "page": page,
+            "page_size": page_size,
+            "total": len(events),
+        }
+
+    def coverage(self, *, period_start, period_end) -> dict[str, Any]:
+        _, start, end, events = self._events(
+            categories=AUDIT_CATEGORIES,
+            period_start=period_start,
+            period_end=period_end,
+        )
+        counts = dict.fromkeys(AUDIT_CATEGORIES, 0)
+        latest = dict.fromkeys(AUDIT_CATEGORIES)
+        for event in sorted(events, key=_event_sort_key):
+            category = event["category"]
+            counts[category] += 1
+            latest[category] = event["occurred_at"]
+        return {
+            "period_start": _iso(start, "period_start"),
+            "period_end": _iso(end, "period_end"),
+            "categories": [
+                {
+                    "category": category,
+                    "count": counts[category],
+                    "latest_at": latest[category],
+                    "available": counts[category] > 0,
+                }
+                for category in AUDIT_CATEGORIES
+            ],
+        }
+
+    def _sign(self, seal: dict[str, Any]) -> str:
+        return hmac.new(
+            self._secret,
+            _canonical(_signature_payload(seal)),
+            hashlib.sha256,
+        ).hexdigest()
+
+    def create_seal(
+        self,
+        *,
+        period_start,
+        period_end,
+        actor_uid,
+        categories=None,
+    ) -> dict[str, Any]:
+        selected, start, end, events = self._events(
+            categories=categories,
+            period_start=period_start,
+            period_end=period_end,
+        )
+        if end > _utc(self.now_factory(), "current_time"):
+            raise GovernanceAuditInvalid(
+                "evidence seal period_end cannot be in the future"
+            )
+        if len(events) > MAX_SEAL_EVENTS:
+            raise GovernanceAuditInvalid(
+                "evidence seal cannot contain more than 50,000 events"
+            )
+        seal = {
+            "uid": self.uid_factory(),
+            "period_start": _iso(start, "period_start"),
+            "period_end": _iso(end, "period_end"),
+            "categories": list(selected),
+            "event_count": len(events),
+            "root_hash": _root(events),
+            "key_version": self.key_version,
+            "sealed_by": _required_text(actor_uid, "actor_uid", 160),
+        }
+        seal["signature"] = self._sign(seal)
+        return self.repository.save_seal(seal)
+
+    def verify_seal(self, seal_uid) -> dict[str, Any]:
+        seal = self.repository.get_seal(
+            _required_text(seal_uid, "seal_uid", 160)
+        )
+        if seal is None:
+            raise GovernanceAuditNotFound("evidence seal not found")
+        expected_signature = self._sign(seal)
+        if not hmac.compare_digest(
+            expected_signature, str(seal.get("signature") or "")
+        ):
+            return {
+                **seal,
+                "integrity_status": "invalid_signature",
+                "actual_event_count": None,
+                "actual_root_hash": None,
+            }
+
+        categories, _, _, events = self._events(
+            categories=seal["categories"],
+            period_start=datetime.fromisoformat(
+                seal["period_start"].replace("Z", "+00:00")
+            ),
+            period_end=datetime.fromisoformat(
+                seal["period_end"].replace("Z", "+00:00")
+            ),
+        )
+        actual_root = _root(events)
+        intact = (
+            list(categories) == list(seal["categories"])
+            and len(events) == int(seal["event_count"])
+            and hmac.compare_digest(actual_root, seal["root_hash"])
+        )
+        return {
+            **seal,
+            "integrity_status": "intact" if intact else "tampered",
+            "actual_event_count": len(events),
+            "actual_root_hash": actual_root,
+        }
+
+    def list_seals(self, *, limit=50):
+        try:
+            limit = int(limit)
+        except (TypeError, ValueError) as exc:
+            raise GovernanceAuditInvalid("limit must be an integer") from exc
+        if limit < 1 or limit > 100:
+            raise GovernanceAuditInvalid("limit must be between 1 and 100")
+        return list(self.repository.list_seals(limit=limit))

+ 357 - 0
app/core/system/governance_audit_repository.py

@@ -0,0 +1,357 @@
+"""Safe PostgreSQL projections for governance audit and runtime evidence."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import text
+
+_EVENT_QUERIES = {
+    "authentication": """
+        SELECT
+            'authentication:' || audit.id::text AS event_uid,
+            'authentication' AS category,
+            audit.event_type AS action,
+            CASE WHEN audit.success THEN 'success' ELSE 'failed' END AS status,
+            COALESCE(audit.user_id::text, audit.username) AS actor_uid,
+            'user' AS resource_type,
+            audit.user_id::text AS resource_uid,
+            audit.created_at AS occurred_at,
+            jsonb_build_object('username', audit.username) AS safe_detail
+        FROM public.auth_audit_events audit
+        WHERE audit.created_at >= :period_start
+          AND audit.created_at <= :period_end
+    """,
+    "ingestion": """
+        SELECT
+            'ingestion:' || job.uid::text AS event_uid,
+            'ingestion' AS category,
+            job.job_type AS action,
+            job.status AS status,
+            job.actor_uid AS actor_uid,
+            'ingestion_job' AS resource_type,
+            job.uid::text AS resource_uid,
+            job.created_at AS occurred_at,
+            jsonb_build_object(
+                'source_uid', job.source_uid::text,
+                'attempt_count', job.attempt_count,
+                'failure_stage', job.failure_stage
+            ) AS safe_detail
+        FROM public.ingestion_jobs job
+        WHERE job.created_at >= :period_start
+          AND job.created_at <= :period_end
+    """,
+    "entity_resolution": """
+        SELECT *
+        FROM (
+            SELECT
+                'entity-review:' || review.uid::text AS event_uid,
+                'entity_resolution' AS category,
+                CASE
+                    WHEN review.decision IN ('approve', 'auto_approve')
+                        THEN 'merge_approved'
+                    ELSE 'merge_rejected'
+                END AS action,
+                review.decision AS status,
+                review.actor_uid AS actor_uid,
+                'entity_candidate' AS resource_type,
+                review.candidate_uid::text AS resource_uid,
+                review.created_at AS occurred_at,
+                jsonb_build_object('version', review.version) AS safe_detail
+            FROM public.device_entity_match_reviews review
+            UNION ALL
+            SELECT
+                'entity-rollback:' || rollback.uid::text AS event_uid,
+                'entity_resolution' AS category,
+                'merge_rolled_back' AS action,
+                'success' AS status,
+                rollback.actor_uid AS actor_uid,
+                'entity_merge' AS resource_type,
+                rollback.merge_uid::text AS resource_uid,
+                rollback.created_at AS occurred_at,
+                jsonb_build_object(
+                    'candidate_uid', rollback.candidate_uid::text
+                ) AS safe_detail
+            FROM public.device_entity_merge_rollbacks rollback
+        ) evidence
+        WHERE evidence.occurred_at >= :period_start
+          AND evidence.occurred_at <= :period_end
+    """,
+    "publication": """
+        SELECT *
+        FROM (
+            SELECT
+                'ontology-publish:' || run.uid::text AS event_uid,
+                'publication' AS category,
+                'ontology_publish' AS action,
+                run.status AS status,
+                run.actor_uid AS actor_uid,
+                'ontology' AS resource_type,
+                run.ontology_uid::text AS resource_uid,
+                COALESCE(run.finished_at, run.created_at) AS occurred_at,
+                jsonb_build_object(
+                    'version_uid', run.version_uid::text
+                ) AS safe_detail
+            FROM public.ontology_publish_runs run
+            UNION ALL
+            SELECT
+                'semantic-review:' || review.uid::text AS event_uid,
+                'publication' AS category,
+                CASE
+                    WHEN review.decision = 'approve'
+                        THEN 'semantic_code_publish'
+                    ELSE 'semantic_code_reject'
+                END AS action,
+                review.decision AS status,
+                review.actor_uid AS actor_uid,
+                'semantic_code' AS resource_type,
+                review.code_uid::text AS resource_uid,
+                review.created_at AS occurred_at,
+                jsonb_build_object('version', review.version) AS safe_detail
+            FROM public.device_semantic_code_reviews review
+            UNION ALL
+            SELECT
+                'quality-publish:' || version.uid::text AS event_uid,
+                'publication' AS category,
+                'quality_profile_publish' AS action,
+                version.status AS status,
+                version.published_by AS actor_uid,
+                'quality_profile' AS resource_type,
+                version.profile_uid::text AS resource_uid,
+                version.published_at AS occurred_at,
+                jsonb_build_object(
+                    'version', version.version,
+                    'content_hash', version.content_hash
+                ) AS safe_detail
+            FROM public.device_quality_profile_versions version
+            WHERE version.published_at IS NOT NULL
+        ) evidence
+        WHERE evidence.occurred_at >= :period_start
+          AND evidence.occurred_at <= :period_end
+    """,
+    "remediation": """
+        SELECT
+            'remediation:' || timeline.uid::text AS event_uid,
+            'remediation' AS category,
+            timeline.action AS action,
+            timeline.to_status AS status,
+            timeline.actor_uid::text AS actor_uid,
+            'quality_issue' AS resource_type,
+            timeline.issue_uid::text AS resource_uid,
+            timeline.created_at AS occurred_at,
+            jsonb_build_object(
+                'from_status', timeline.from_status,
+                'to_status', timeline.to_status
+            ) AS safe_detail
+        FROM public.device_quality_issue_timeline timeline
+        WHERE timeline.created_at >= :period_start
+          AND timeline.created_at <= :period_end
+    """,
+    "knowledge_query": """
+        SELECT
+            'knowledge-query:' || audit.id::text AS event_uid,
+            'knowledge_query' AS category,
+            'knowledge_query' AS action,
+            CASE
+                WHEN jsonb_array_length(audit.degraded_components) > 0
+                    THEN 'degraded'
+                ELSE 'success'
+            END AS status,
+            audit.user_id::text AS actor_uid,
+            'knowledge_query' AS resource_type,
+            audit.correlation_id::text AS resource_uid,
+            audit.created_at AS occurred_at,
+            jsonb_build_object(
+                'mode', audit.mode,
+                'citation_count', jsonb_array_length(audit.cited_points),
+                'degraded_count',
+                    jsonb_array_length(audit.degraded_components),
+                'latency_ms', audit.latency_ms
+            ) AS safe_detail
+        FROM public.knowledge_query_audits audit
+        WHERE audit.created_at >= :period_start
+          AND audit.created_at <= :period_end
+    """,
+}
+
+_SEAL_FIELDS = """
+    uid::text AS uid,
+    period_start,
+    period_end,
+    categories,
+    event_count,
+    root_hash,
+    signature,
+    key_version,
+    sealed_by,
+    created_at
+"""
+
+
+def _as_dict(row) -> dict[str, Any]:
+    return dict(row)
+
+
+def _iso(value):
+    if isinstance(value, datetime):
+        return value.isoformat().replace("+00:00", "Z")
+    return value
+
+
+def _seal(row) -> dict[str, Any]:
+    record = _as_dict(row)
+    record["period_start"] = _iso(record.get("period_start"))
+    record["period_end"] = _iso(record.get("period_end"))
+    record["created_at"] = _iso(record.get("created_at"))
+    record["categories"] = list(record.get("categories") or [])
+    record["event_count"] = int(record.get("event_count") or 0)
+    return record
+
+
+class SqlAlchemyGovernanceAuditRepository:
+    """Read safe audit projections and persist append-only evidence seals."""
+
+    def __init__(self, session):
+        self.session = session
+
+    def fetch_events(
+        self,
+        *,
+        categories,
+        period_start,
+        period_end,
+    ) -> list[dict[str, Any]]:
+        records = []
+        params = {
+            "period_start": period_start,
+            "period_end": period_end,
+        }
+        for category in categories:
+            query = _EVENT_QUERIES.get(category)
+            if query is None:
+                continue
+            rows = self.session.execute(text(query), params).mappings().all()
+            records.extend(_as_dict(row) for row in rows)
+        return records
+
+    def security_snapshot(self):
+        credential = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT
+                        COUNT(*) AS credential_count,
+                        COUNT(*) FILTER (
+                            WHERE octet_length(encrypted_payload) > 0
+                              AND octet_length(nonce) = 12
+                              AND NULLIF(BTRIM(key_version), '') IS NOT NULL
+                        ) AS encrypted_credential_count
+                    FROM public.datasource_credentials
+                    """
+                )
+            )
+            .mappings()
+            .one()
+        )
+        source = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT COUNT(*) AS plaintext_source_config_count
+                    FROM public.ingestion_sources
+                    WHERE jsonb_path_exists(
+                        config,
+                        '$.** ? (@.type() == "object").keyvalue() ? (
+                            @.key like_regex
+                            "^(password|passwd|credential|credentials|api_key|apikey|token|authorization|connection_string|connection_url|conn_str)$"
+                            flag "i"
+                        )'
+                    )
+                    """
+                )
+            )
+            .mappings()
+            .one()
+        )
+        return {
+            "credential_count": int(credential["credential_count"] or 0),
+            "encrypted_credential_count": int(
+                credential["encrypted_credential_count"] or 0
+            ),
+            "plaintext_source_config_count": int(
+                source["plaintext_source_config_count"] or 0
+            ),
+        }
+
+    def save_seal(self, seal):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.governance_audit_seals (
+                        uid, period_start, period_end, categories,
+                        event_count, root_hash, signature, key_version,
+                        sealed_by
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:period_start AS timestamptz),
+                        CAST(:period_end AS timestamptz),
+                        CAST(:categories AS jsonb), :event_count,
+                        :root_hash, :signature, :key_version, :sealed_by
+                    )
+                    RETURNING
+                    """
+                    + _SEAL_FIELDS
+                ),
+                {
+                    **seal,
+                    "categories": json.dumps(
+                        list(seal["categories"]),
+                        ensure_ascii=False,
+                        separators=(",", ":"),
+                    ),
+                },
+            )
+            .mappings()
+            .one()
+        )
+        self.session.flush()
+        return _seal(row)
+
+    def get_seal(self, seal_uid):
+        row = (
+            self.session.execute(
+                text(
+                    "SELECT "
+                    + _SEAL_FIELDS
+                    + """
+                    FROM public.governance_audit_seals
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": seal_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _seal(row) if row is not None else None
+
+    def list_seals(self, *, limit):
+        rows = (
+            self.session.execute(
+                text(
+                    "SELECT "
+                    + _SEAL_FIELDS
+                    + """
+                    FROM public.governance_audit_seals
+                    ORDER BY created_at DESC, uid DESC
+                    LIMIT :limit
+                    """
+                ),
+                {"limit": int(limit)},
+            )
+            .mappings()
+            .all()
+        )
+        return [_seal(row) for row in rows]

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

@@ -44,6 +44,8 @@ DEVICE_QUALITY_PUBLISH = "device-quality:publish"
 QUALITY_ISSUES_EDIT = "quality-issues:edit"
 QUALITY_ISSUES_REVIEW = "quality-issues:review"
 DEVICE_OBSERVABILITY_EDIT = "device-observability:edit"
+GOVERNANCE_AUDIT_READ = "governance-audit:read"
+GOVERNANCE_AUDIT_SEAL = "governance-audit:seal"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -110,6 +112,8 @@ ROLE_PERMISSIONS = {
             QUALITY_ISSUES_EDIT,
             QUALITY_ISSUES_REVIEW,
             DEVICE_OBSERVABILITY_EDIT,
+            GOVERNANCE_AUDIT_READ,
+            GOVERNANCE_AUDIT_SEAL,
         }
     ),
 }
@@ -126,6 +130,10 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if method == "GET":
             return (RESPONSIBILITIES_READ,)
         return (RESPONSIBILITIES_MANAGE,)
+    if path.startswith("/api/system/governance-audit"):
+        if method == "GET":
+            return (GOVERNANCE_AUDIT_READ,)
+        return (GOVERNANCE_AUDIT_SEAL,)
     if path in {"/api/knowledge/search", "/api/knowledge/ask"}:
         return (READ_GOVERNANCE,)
     if path.startswith("/api/rules"):

+ 5 - 0
deploy/docker/.env.example

@@ -5,6 +5,11 @@ DEEPSEEK_API_KEY=
 # Generate a dedicated value with: openssl rand -hex 32
 RULE_GENERATION_RECEIPT_SECRET=replace-with-dedicated-64-hex-random-value
 
+# Dedicated HMAC key for WP12 governance audit evidence seals.
+# Generate independently with: openssl rand -hex 32
+AUDIT_EVIDENCE_SECRET=replace-with-dedicated-64-hex-random-value
+AUDIT_EVIDENCE_KEY_VERSION=v1
+
 # Create this key in the local n8n UI after owner setup, then restart backend.
 N8N_API_KEY=
 

+ 2 - 0
deploy/docker/docker-compose.yml

@@ -411,6 +411,8 @@ services:
       QWEN_EMBEDDING_API_KEY: ${QWEN_EMBEDDING_API_KEY:-}
       QWEN_EMBEDDING_MODEL: ${QWEN_EMBEDDING_MODEL:-text-embedding-v3}
       SECRET_KEY: dataops-local-test-secret-key
+      AUDIT_EVIDENCE_SECRET: ${AUDIT_EVIDENCE_SECRET:-dataops-local-audit-evidence-secret-change-me}
+      AUDIT_EVIDENCE_KEY_VERSION: ${AUDIT_EVIDENCE_KEY_VERSION:-local-v1}
       RULE_GENERATION_RECEIPT_SECRET: ${RULE_GENERATION_RECEIPT_SECRET:-}
       DATASOURCE_CREDENTIAL_MASTER_KEY: ${DATASOURCE_CREDENTIAL_MASTER_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
       DATASOURCE_CREDENTIAL_KEY_VERSION: ${DATASOURCE_CREDENTIAL_KEY_VERSION:-v1}

+ 4 - 0
deployment/.env.production.example

@@ -6,6 +6,10 @@ SECRET_KEY=replace-with-a-long-random-secret
 # Dedicated HMAC key for short-lived AI rule generation receipts.
 # Generate independently with: openssl rand -hex 32
 RULE_GENERATION_RECEIPT_SECRET=replace-with-dedicated-64-hex-random-value
+# Dedicated HMAC key for governance audit evidence seals.
+# Generate independently with: openssl rand -hex 32
+AUDIT_EVIDENCE_SECRET=replace-with-dedicated-64-hex-random-value
+AUDIT_EVIDENCE_KEY_VERSION=v1
 DEBUG=False
 
 # Gunicorn / Flask 监听端口(保持一致,默认 5500)

+ 42 - 8
deployment/app/__init__.py

@@ -1,5 +1,6 @@
 import logging
 import os
+import uuid
 
 from flask import Flask, jsonify
 from flask_cors import CORS
@@ -32,18 +33,21 @@ def create_app():
 
     # 注册蓝图
     from app.api.business_domain import bp as business_domain_bp
+    from app.api.data_development import bp as data_development_bp
     from app.api.data_factory import bp as data_factory_bp
     from app.api.data_flow import bp as data_flow_bp
     from app.api.data_interface import bp as data_interface_bp
+    from app.api.data_rules import bp as data_rules_bp
     from app.api.data_service import bp as data_service_bp
     from app.api.data_source import bp as data_source_bp
-    from app.api.data_development import bp as data_development_bp
     from app.api.graph import bp as graph_bp
+    from app.api.knowledge_base import bp as knowledge_base_bp
     from app.api.meta_data import bp as meta_bp
     from app.api.system import bp as system_bp
 
     app.register_blueprint(meta_bp, url_prefix="/api/meta")
     app.register_blueprint(data_interface_bp, url_prefix="/api/interface")
+    app.register_blueprint(data_rules_bp, url_prefix="/api/rules")
     app.register_blueprint(graph_bp, url_prefix="/api/graph")
     app.register_blueprint(system_bp, url_prefix="/api/system")
     app.register_blueprint(data_source_bp, url_prefix="/api/datasource")
@@ -52,6 +56,7 @@ def create_app():
     app.register_blueprint(business_domain_bp, url_prefix="/api/bd")
     app.register_blueprint(data_factory_bp, url_prefix="/api/datafactory")
     app.register_blueprint(data_service_bp, url_prefix="/api/dataservice")
+    app.register_blueprint(knowledge_base_bp, url_prefix="/api/knowledge")
 
     from app.core.system.permissions import configure_api_authorization
 
@@ -165,6 +170,19 @@ def configure_response_headers(app):
                 response.headers["X-Frame-Options"] = "DENY"
             if "X-XSS-Protection" not in response.headers:
                 response.headers["X-XSS-Protection"] = "1; mode=block"
+            if "Referrer-Policy" not in response.headers:
+                response.headers["Referrer-Policy"] = "no-referrer"
+            if "Permissions-Policy" not in response.headers:
+                response.headers["Permissions-Policy"] = (
+                    "camera=(), microphone=(), geolocation=()"
+                )
+            if (
+                request.path.startswith("/api/system/auth")
+                or request.path.startswith(
+                    "/api/system/governance-audit"
+                )
+            ):
+                response.headers["Cache-Control"] = "no-store"
 
         if request.path.startswith("/api/") and request.path != "/api/system/health":
             app.logger.info(
@@ -237,14 +255,20 @@ def configure_error_handlers(app):
     @app.errorhandler(Exception)
     def handle_exception(e):
         """全局异常处理器,捕获所有未处理的异常"""
-        # 记录详细的错误信息
-        app.logger.error(f"未处理的异常: {str(e)}", exc_info=True)
-
-        # 返回标准化的错误响应
+        from app.core.data_source.redaction import sanitize_exception
+
+        correlation_id = str(uuid.uuid4())
+        app.logger.error(
+            "未处理的异常 correlation_id=%s type=%s detail=%s",
+            correlation_id,
+            type(e).__name__,
+            sanitize_exception(e, limit=300),
+        )
         error_response = {
             "success": False,
-            "message": f"服务器内部错误: {str(e)}",
+            "message": "服务器内部错误",
             "data": None,
+            "correlation_id": correlation_id,
         }
 
         return jsonify(error_response), 500
@@ -260,7 +284,17 @@ def configure_error_handlers(app):
     @app.errorhandler(500)
     def handle_internal_error(e):
         """处理500错误"""
-        app.logger.error(f"500错误: {str(e)}", exc_info=True)
+        correlation_id = str(uuid.uuid4())
+        app.logger.error(
+            "500错误 correlation_id=%s type=%s",
+            correlation_id,
+            type(e).__name__,
+        )
         return jsonify(
-            {"success": False, "message": "服务器内部错误", "data": None}
+            {
+                "success": False,
+                "message": "服务器内部错误",
+                "data": None,
+                "correlation_id": correlation_id,
+            }
         ), 500

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

@@ -1,7 +1,11 @@
+# ruff: noqa: E402, F401, I001
+
 from flask import Blueprint
 
 bp = Blueprint("system", __name__)
 
+from app.api.system import governance_audit  # noqa: E402, F401
+from app.api.system import responsibilities  # noqa: E402, F401
 from app.api.system import routes  # noqa: E402, F401
 from app.api.system import users  # noqa: E402, F401
 from app.api.system import workbench  # noqa: E402, F401

+ 299 - 0
deployment/app/api/system/governance_audit.py

@@ -0,0 +1,299 @@
+"""Admin-only governance audit and tamper-evident runtime evidence APIs."""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+from datetime import UTC, datetime, timedelta
+
+from flask import current_app, g, jsonify, request
+
+from app import db
+from app.api.system import bp
+from app.config.config import is_placeholder_env_value
+from app.core.data_source.redaction import sanitize_exception
+from app.core.system.governance_audit import (
+    GovernanceAuditInvalid,
+    GovernanceAuditNotFound,
+    GovernanceAuditService,
+)
+from app.core.system.governance_audit_repository import (
+    SqlAlchemyGovernanceAuditRepository,
+)
+from app.models.result import failed, success
+
+logger = logging.getLogger(__name__)
+
+
+class GovernanceAuditUnavailable(RuntimeError):
+    """Raised when the server cannot safely perform a sealing operation."""
+
+
+def _effective_evidence_secret() -> tuple[str, bool]:
+    dedicated = str(
+        current_app.config.get("AUDIT_EVIDENCE_SECRET") or ""
+    ).strip()
+    dedicated_ready = (
+        len(dedicated.encode("utf-8")) >= 32
+        and not is_placeholder_env_value(dedicated)
+    )
+    if dedicated_ready:
+        return dedicated, True
+    fallback = hashlib.sha256(
+        (
+            "dataops-wp12-local-audit:"
+            + str(current_app.config.get("SECRET_KEY") or "")
+        ).encode("utf-8")
+    ).hexdigest()
+    return fallback, False
+
+
+def _sealing_allowed(dedicated_ready: bool) -> bool:
+    if dedicated_ready:
+        return True
+    if current_app.config.get("TESTING"):
+        return True
+    return (
+        str(current_app.config.get("FLASK_ENV") or "").lower()
+        != "production"
+    )
+
+
+def _require_sealing_key() -> None:
+    _secret, dedicated_ready = _effective_evidence_secret()
+    if not _sealing_allowed(dedicated_ready):
+        raise GovernanceAuditUnavailable(
+            "dedicated audit evidence key is required"
+        )
+
+
+def get_governance_audit_service():
+    secret, _dedicated = _effective_evidence_secret()
+    return GovernanceAuditService(
+        SqlAlchemyGovernanceAuditRepository(db.session),
+        evidence_secret=secret,
+        key_version=str(
+            current_app.config.get("AUDIT_EVIDENCE_KEY_VERSION")
+            or "local-fallback-v1"
+        ),
+    )
+
+
+def get_security_checks():
+    repository = SqlAlchemyGovernanceAuditRepository(db.session)
+    snapshot = repository.security_snapshot()
+    _secret, dedicated_ready = _effective_evidence_secret()
+    credential_count = snapshot["credential_count"]
+    encrypted_count = snapshot["encrypted_credential_count"]
+    plaintext_count = snapshot["plaintext_source_config_count"]
+    return {
+        "seal_ready": _sealing_allowed(dedicated_ready),
+        "production_key_ready": dedicated_ready,
+        "assurance": (
+            "签名封存用于检测篡改,不等于阻止数据库管理员修改;"
+            "生产环境必须使用独立密钥并纳入企业密钥保管与轮换。"
+        ),
+        "checks": [
+            {
+                "code": "datasource_credential_encryption",
+                "name": "数据源凭据密文存储",
+                "status": (
+                    "passed"
+                    if credential_count == encrypted_count
+                    else "failed"
+                ),
+                "observed_count": encrypted_count,
+                "expected_count": credential_count,
+            },
+            {
+                "code": "legacy_plaintext_cleanup",
+                "name": "采集源配置无明文秘密字段",
+                "status": "passed" if plaintext_count == 0 else "failed",
+                "observed_count": plaintext_count,
+                "expected_count": 0,
+            },
+            {
+                "code": "dedicated_evidence_key",
+                "name": "独立审计封存密钥",
+                "status": "passed" if dedicated_ready else "warning",
+                "reason_code": (
+                    "dedicated_key_configured"
+                    if dedicated_ready
+                    else "local_fallback_key_in_use"
+                ),
+            },
+            {
+                "code": "safe_error_boundary",
+                "name": "异常与日志脱敏边界",
+                "status": "passed",
+            },
+            {
+                "code": "security_response_headers",
+                "name": "API 安全响应头",
+                "status": "passed",
+            },
+            {
+                "code": "audit_source_coverage",
+                "name": "六类关键操作审计源",
+                "status": "passed",
+                "expected_count": 6,
+            },
+        ],
+    }
+
+
+def _timestamp(value, *, default=None):
+    if value in (None, ""):
+        if default is None:
+            raise GovernanceAuditInvalid("timestamp is required")
+        return default
+    try:
+        parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+    except (TypeError, ValueError) as exc:
+        raise GovernanceAuditInvalid("timestamp must be ISO-8601") from exc
+    if parsed.tzinfo is None:
+        raise GovernanceAuditInvalid("timestamp must include a timezone")
+    return parsed.astimezone(UTC)
+
+
+def _window(values):
+    now = datetime.now(UTC)
+    return (
+        _timestamp(
+            values.get("period_start"),
+            default=now - timedelta(days=30),
+        ),
+        _timestamp(values.get("period_end"), default=now),
+    )
+
+
+def _categories(values):
+    if hasattr(values, "getlist"):
+        result = values.getlist("category")
+        if result:
+            return result
+    return values.get("categories") or values.get("category")
+
+
+def _safe_error(error, status=503):
+    logger.error(
+        "governance audit request failed: %s",
+        sanitize_exception(error, limit=300),
+    )
+    return (
+        jsonify(
+            failed(
+                "审计与运行证据暂不可用",
+                code=status,
+                error={"code": "GOVERNANCE_AUDIT_UNAVAILABLE"},
+            )
+        ),
+        status,
+    )
+
+
+def _execute(operation):
+    try:
+        return jsonify(success(operation()))
+    except GovernanceAuditInvalid as exc:
+        return (
+            jsonify(
+                failed(
+                    str(exc),
+                    code=400,
+                    error={"code": "GOVERNANCE_AUDIT_INVALID"},
+                )
+            ),
+            400,
+        )
+    except GovernanceAuditNotFound:
+        return (
+            jsonify(
+                failed(
+                    "审计证据封存不存在",
+                    code=404,
+                    error={"code": "GOVERNANCE_AUDIT_NOT_FOUND"},
+                )
+            ),
+            404,
+        )
+    except GovernanceAuditUnavailable:
+        return _safe_error(
+            GovernanceAuditUnavailable("audit sealing is not configured")
+        )
+    except Exception as exc:
+        db.session.rollback()
+        return _safe_error(exc)
+
+
+@bp.route("/governance-audit/security-checks", methods=["GET"])
+def governance_audit_security_checks():
+    return _execute(get_security_checks)
+
+
+@bp.route("/governance-audit/coverage", methods=["GET"])
+def governance_audit_coverage():
+    def operation():
+        start, end = _window(request.args)
+        return get_governance_audit_service().coverage(
+            period_start=start,
+            period_end=end,
+        )
+
+    return _execute(operation)
+
+
+@bp.route("/governance-audit/events", methods=["GET"])
+def governance_audit_events():
+    def operation():
+        start, end = _window(request.args)
+        return get_governance_audit_service().list_events(
+            period_start=start,
+            period_end=end,
+            categories=_categories(request.args),
+            action=request.args.get("action"),
+            status=request.args.get("status"),
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+
+    return _execute(operation)
+
+
+@bp.route("/governance-audit/seals", methods=["GET"])
+def governance_audit_seals():
+    return _execute(
+        lambda: get_governance_audit_service().list_seals(
+            limit=request.args.get("limit", 50)
+        )
+    )
+
+
+@bp.route("/governance-audit/seals", methods=["POST"])
+def create_governance_audit_seal():
+    def operation():
+        _require_sealing_key()
+        payload = request.get_json(silent=True) or {}
+        start, end = _window(payload)
+        seal = get_governance_audit_service().create_seal(
+            period_start=start,
+            period_end=end,
+            categories=_categories(payload),
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return seal
+
+    return _execute(operation)
+
+
+@bp.route(
+    "/governance-audit/seals/<seal_uid>/verify",
+    methods=["POST"],
+)
+def verify_governance_audit_seal(seal_uid):
+    def operation():
+        _require_sealing_key()
+        return get_governance_audit_service().verify_seal(seal_uid)
+
+    return _execute(operation)

+ 6 - 0
deployment/app/config/config.py

@@ -279,6 +279,12 @@ def apply_runtime_env_config(app) -> None:
                 "DATASOURCE_CERT_DIR",
                 "/etc/dataops-platform/datasource-certs",
             ),
+            "AUDIT_EVIDENCE_SECRET": _clean_env(
+                "AUDIT_EVIDENCE_SECRET"
+            ),
+            "AUDIT_EVIDENCE_KEY_VERSION": _clean_env(
+                "AUDIT_EVIDENCE_KEY_VERSION", "local-fallback-v1"
+            ),
         }
     )
 

+ 5 - 1
deployment/app/core/data_source/redaction.py

@@ -4,7 +4,6 @@ import re
 from collections.abc import Mapping
 from urllib.parse import urlsplit, urlunsplit
 
-
 REDACTED = "[redacted]"
 SENSITIVE_KEYS = {
     "password",
@@ -31,6 +30,7 @@ _NAMED_SECRET = re.compile(
     r"(?i)(password|passwd|api[_-]?key|token|authorization)"
     r"(\s*[=:]\s*)([^\s,;]+)"
 )
+_BEARER_SECRET = re.compile(r"(?i)(bearer)(\s+)([^\s,;]+)")
 
 
 def _redact_url(value: str) -> str:
@@ -102,4 +102,8 @@ def sanitize_exception(error: BaseException, limit: int = 1000) -> str:
         lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}",
         value,
     )
+    value = _BEARER_SECRET.sub(
+        lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}",
+        value,
+    )
     return value[: max(0, int(limit))]

+ 328 - 0
deployment/app/core/system/governance_audit.py

@@ -0,0 +1,328 @@
+"""Governance audit normalization and tamper-evident evidence sealing."""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+from datetime import UTC, datetime
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_source.redaction import redact_mapping
+
+AUDIT_CATEGORIES = (
+    "authentication",
+    "ingestion",
+    "entity_resolution",
+    "publication",
+    "remediation",
+    "knowledge_query",
+)
+MAX_SEAL_EVENTS = 50_000
+
+
+class GovernanceAuditInvalid(ValueError):
+    """Raised when an audit query or seal request is invalid."""
+
+
+class GovernanceAuditNotFound(LookupError):
+    """Raised when a requested evidence seal does not exist."""
+
+
+def _utc(value: datetime, label: str) -> datetime:
+    if not isinstance(value, datetime):
+        raise GovernanceAuditInvalid(f"{label} must be a datetime")
+    if value.tzinfo is None:
+        raise GovernanceAuditInvalid(f"{label} must include a timezone")
+    return value.astimezone(UTC)
+
+
+def _iso(value: datetime, label: str) -> str:
+    return _utc(value, label).isoformat().replace("+00:00", "Z")
+
+
+def _canonical(value: Any) -> bytes:
+    return json.dumps(
+        value,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    ).encode("utf-8")
+
+
+def _digest(value: Any) -> str:
+    return hashlib.sha256(_canonical(value)).hexdigest()
+
+
+def _required_text(value: Any, label: str, maximum: int) -> str:
+    normalized = str(value or "").strip()
+    if not normalized:
+        raise GovernanceAuditInvalid(f"{label} is required")
+    if len(normalized) > maximum:
+        raise GovernanceAuditInvalid(f"{label} exceeds {maximum} characters")
+    return normalized
+
+
+def _normalize_categories(categories=None) -> tuple[str, ...]:
+    if categories is None:
+        return AUDIT_CATEGORIES
+    if isinstance(categories, str):
+        categories = [categories]
+    normalized = tuple(
+        dict.fromkeys(str(item).strip() for item in categories if str(item).strip())
+    )
+    if not normalized:
+        raise GovernanceAuditInvalid("at least one category is required")
+    unsupported = sorted(set(normalized) - set(AUDIT_CATEGORIES))
+    if unsupported:
+        raise GovernanceAuditInvalid(
+            f"unsupported category: {', '.join(unsupported)}"
+        )
+    return tuple(item for item in AUDIT_CATEGORIES if item in normalized)
+
+
+def _normalize_window(period_start, period_end) -> tuple[datetime, datetime]:
+    start = _utc(period_start, "period_start")
+    end = _utc(period_end, "period_end")
+    if end <= start:
+        raise GovernanceAuditInvalid("period_end must be after period_start")
+    return start, end
+
+
+def _normalize_event(event: dict[str, Any]) -> dict[str, Any]:
+    category = _required_text(event.get("category"), "category", 40)
+    if category not in AUDIT_CATEGORIES:
+        raise GovernanceAuditInvalid(f"unsupported category: {category}")
+    occurred_at = event.get("occurred_at")
+    return {
+        "event_uid": _required_text(event.get("event_uid"), "event_uid", 160),
+        "category": category,
+        "action": _required_text(event.get("action"), "action", 80),
+        "status": _required_text(event.get("status"), "status", 40),
+        "actor_uid": str(event.get("actor_uid") or "")[:160] or None,
+        "resource_type": _required_text(
+            event.get("resource_type"), "resource_type", 80
+        ),
+        "resource_uid": str(event.get("resource_uid") or "")[:200] or None,
+        "occurred_at": _iso(occurred_at, "occurred_at"),
+        "safe_detail": redact_mapping(dict(event.get("safe_detail") or {})),
+    }
+
+
+def _event_sort_key(event: dict[str, Any]) -> tuple[str, str]:
+    return event["occurred_at"], event["event_uid"]
+
+
+def _root(events: list[dict[str, Any]]) -> str:
+    ordered = sorted(events, key=_event_sort_key)
+    return _digest([_digest(event) for event in ordered])
+
+
+def _signature_payload(seal: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "uid": seal["uid"],
+        "period_start": seal["period_start"],
+        "period_end": seal["period_end"],
+        "categories": list(seal["categories"]),
+        "event_count": int(seal["event_count"]),
+        "root_hash": seal["root_hash"],
+        "key_version": seal["key_version"],
+        "sealed_by": seal["sealed_by"],
+    }
+
+
+class GovernanceAuditService:
+    """Normalize distributed audit evidence and create signed evidence seals."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        evidence_secret: str,
+        key_version: str,
+        uid_factory=new_governance_uid,
+        now_factory=None,
+    ):
+        secret = str(evidence_secret or "").encode("utf-8")
+        if len(secret) < 32:
+            raise GovernanceAuditInvalid(
+                "audit evidence secret must contain at least 32 bytes"
+            )
+        self.repository = repository
+        self._secret = secret
+        self.key_version = _required_text(key_version, "key_version", 80)
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory or (lambda: datetime.now(UTC))
+
+    def _events(self, *, categories, period_start, period_end):
+        normalized_categories = _normalize_categories(categories)
+        start, end = _normalize_window(period_start, period_end)
+        events = [
+            _normalize_event(event)
+            for event in self.repository.fetch_events(
+                categories=normalized_categories,
+                period_start=start,
+                period_end=end,
+            )
+        ]
+        return normalized_categories, start, end, events
+
+    def list_events(
+        self,
+        *,
+        period_start,
+        period_end,
+        categories=None,
+        action=None,
+        status=None,
+        page=1,
+        page_size=20,
+    ) -> dict[str, Any]:
+        try:
+            page = int(page)
+            page_size = int(page_size)
+        except (TypeError, ValueError) as exc:
+            raise GovernanceAuditInvalid(
+                "page and page_size must be integers"
+            ) from exc
+        if page < 1:
+            raise GovernanceAuditInvalid("page must be at least 1")
+        if page_size < 1 or page_size > 100:
+            raise GovernanceAuditInvalid("page_size must be between 1 and 100")
+
+        selected, start, end, events = self._events(
+            categories=categories,
+            period_start=period_start,
+            period_end=period_end,
+        )
+        if action:
+            events = [event for event in events if event["action"] == action]
+        if status:
+            events = [event for event in events if event["status"] == status]
+        events.sort(key=_event_sort_key, reverse=True)
+        offset = (page - 1) * page_size
+        return {
+            "period_start": _iso(start, "period_start"),
+            "period_end": _iso(end, "period_end"),
+            "categories": list(selected),
+            "records": events[offset : offset + page_size],
+            "page": page,
+            "page_size": page_size,
+            "total": len(events),
+        }
+
+    def coverage(self, *, period_start, period_end) -> dict[str, Any]:
+        _, start, end, events = self._events(
+            categories=AUDIT_CATEGORIES,
+            period_start=period_start,
+            period_end=period_end,
+        )
+        counts = dict.fromkeys(AUDIT_CATEGORIES, 0)
+        latest = dict.fromkeys(AUDIT_CATEGORIES)
+        for event in sorted(events, key=_event_sort_key):
+            category = event["category"]
+            counts[category] += 1
+            latest[category] = event["occurred_at"]
+        return {
+            "period_start": _iso(start, "period_start"),
+            "period_end": _iso(end, "period_end"),
+            "categories": [
+                {
+                    "category": category,
+                    "count": counts[category],
+                    "latest_at": latest[category],
+                    "available": counts[category] > 0,
+                }
+                for category in AUDIT_CATEGORIES
+            ],
+        }
+
+    def _sign(self, seal: dict[str, Any]) -> str:
+        return hmac.new(
+            self._secret,
+            _canonical(_signature_payload(seal)),
+            hashlib.sha256,
+        ).hexdigest()
+
+    def create_seal(
+        self,
+        *,
+        period_start,
+        period_end,
+        actor_uid,
+        categories=None,
+    ) -> dict[str, Any]:
+        selected, start, end, events = self._events(
+            categories=categories,
+            period_start=period_start,
+            period_end=period_end,
+        )
+        if end > _utc(self.now_factory(), "current_time"):
+            raise GovernanceAuditInvalid(
+                "evidence seal period_end cannot be in the future"
+            )
+        if len(events) > MAX_SEAL_EVENTS:
+            raise GovernanceAuditInvalid(
+                "evidence seal cannot contain more than 50,000 events"
+            )
+        seal = {
+            "uid": self.uid_factory(),
+            "period_start": _iso(start, "period_start"),
+            "period_end": _iso(end, "period_end"),
+            "categories": list(selected),
+            "event_count": len(events),
+            "root_hash": _root(events),
+            "key_version": self.key_version,
+            "sealed_by": _required_text(actor_uid, "actor_uid", 160),
+        }
+        seal["signature"] = self._sign(seal)
+        return self.repository.save_seal(seal)
+
+    def verify_seal(self, seal_uid) -> dict[str, Any]:
+        seal = self.repository.get_seal(
+            _required_text(seal_uid, "seal_uid", 160)
+        )
+        if seal is None:
+            raise GovernanceAuditNotFound("evidence seal not found")
+        expected_signature = self._sign(seal)
+        if not hmac.compare_digest(
+            expected_signature, str(seal.get("signature") or "")
+        ):
+            return {
+                **seal,
+                "integrity_status": "invalid_signature",
+                "actual_event_count": None,
+                "actual_root_hash": None,
+            }
+
+        categories, _, _, events = self._events(
+            categories=seal["categories"],
+            period_start=datetime.fromisoformat(
+                seal["period_start"].replace("Z", "+00:00")
+            ),
+            period_end=datetime.fromisoformat(
+                seal["period_end"].replace("Z", "+00:00")
+            ),
+        )
+        actual_root = _root(events)
+        intact = (
+            list(categories) == list(seal["categories"])
+            and len(events) == int(seal["event_count"])
+            and hmac.compare_digest(actual_root, seal["root_hash"])
+        )
+        return {
+            **seal,
+            "integrity_status": "intact" if intact else "tampered",
+            "actual_event_count": len(events),
+            "actual_root_hash": actual_root,
+        }
+
+    def list_seals(self, *, limit=50):
+        try:
+            limit = int(limit)
+        except (TypeError, ValueError) as exc:
+            raise GovernanceAuditInvalid("limit must be an integer") from exc
+        if limit < 1 or limit > 100:
+            raise GovernanceAuditInvalid("limit must be between 1 and 100")
+        return list(self.repository.list_seals(limit=limit))

+ 357 - 0
deployment/app/core/system/governance_audit_repository.py

@@ -0,0 +1,357 @@
+"""Safe PostgreSQL projections for governance audit and runtime evidence."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import text
+
+_EVENT_QUERIES = {
+    "authentication": """
+        SELECT
+            'authentication:' || audit.id::text AS event_uid,
+            'authentication' AS category,
+            audit.event_type AS action,
+            CASE WHEN audit.success THEN 'success' ELSE 'failed' END AS status,
+            COALESCE(audit.user_id::text, audit.username) AS actor_uid,
+            'user' AS resource_type,
+            audit.user_id::text AS resource_uid,
+            audit.created_at AS occurred_at,
+            jsonb_build_object('username', audit.username) AS safe_detail
+        FROM public.auth_audit_events audit
+        WHERE audit.created_at >= :period_start
+          AND audit.created_at <= :period_end
+    """,
+    "ingestion": """
+        SELECT
+            'ingestion:' || job.uid::text AS event_uid,
+            'ingestion' AS category,
+            job.job_type AS action,
+            job.status AS status,
+            job.actor_uid AS actor_uid,
+            'ingestion_job' AS resource_type,
+            job.uid::text AS resource_uid,
+            job.created_at AS occurred_at,
+            jsonb_build_object(
+                'source_uid', job.source_uid::text,
+                'attempt_count', job.attempt_count,
+                'failure_stage', job.failure_stage
+            ) AS safe_detail
+        FROM public.ingestion_jobs job
+        WHERE job.created_at >= :period_start
+          AND job.created_at <= :period_end
+    """,
+    "entity_resolution": """
+        SELECT *
+        FROM (
+            SELECT
+                'entity-review:' || review.uid::text AS event_uid,
+                'entity_resolution' AS category,
+                CASE
+                    WHEN review.decision IN ('approve', 'auto_approve')
+                        THEN 'merge_approved'
+                    ELSE 'merge_rejected'
+                END AS action,
+                review.decision AS status,
+                review.actor_uid AS actor_uid,
+                'entity_candidate' AS resource_type,
+                review.candidate_uid::text AS resource_uid,
+                review.created_at AS occurred_at,
+                jsonb_build_object('version', review.version) AS safe_detail
+            FROM public.device_entity_match_reviews review
+            UNION ALL
+            SELECT
+                'entity-rollback:' || rollback.uid::text AS event_uid,
+                'entity_resolution' AS category,
+                'merge_rolled_back' AS action,
+                'success' AS status,
+                rollback.actor_uid AS actor_uid,
+                'entity_merge' AS resource_type,
+                rollback.merge_uid::text AS resource_uid,
+                rollback.created_at AS occurred_at,
+                jsonb_build_object(
+                    'candidate_uid', rollback.candidate_uid::text
+                ) AS safe_detail
+            FROM public.device_entity_merge_rollbacks rollback
+        ) evidence
+        WHERE evidence.occurred_at >= :period_start
+          AND evidence.occurred_at <= :period_end
+    """,
+    "publication": """
+        SELECT *
+        FROM (
+            SELECT
+                'ontology-publish:' || run.uid::text AS event_uid,
+                'publication' AS category,
+                'ontology_publish' AS action,
+                run.status AS status,
+                run.actor_uid AS actor_uid,
+                'ontology' AS resource_type,
+                run.ontology_uid::text AS resource_uid,
+                COALESCE(run.finished_at, run.created_at) AS occurred_at,
+                jsonb_build_object(
+                    'version_uid', run.version_uid::text
+                ) AS safe_detail
+            FROM public.ontology_publish_runs run
+            UNION ALL
+            SELECT
+                'semantic-review:' || review.uid::text AS event_uid,
+                'publication' AS category,
+                CASE
+                    WHEN review.decision = 'approve'
+                        THEN 'semantic_code_publish'
+                    ELSE 'semantic_code_reject'
+                END AS action,
+                review.decision AS status,
+                review.actor_uid AS actor_uid,
+                'semantic_code' AS resource_type,
+                review.code_uid::text AS resource_uid,
+                review.created_at AS occurred_at,
+                jsonb_build_object('version', review.version) AS safe_detail
+            FROM public.device_semantic_code_reviews review
+            UNION ALL
+            SELECT
+                'quality-publish:' || version.uid::text AS event_uid,
+                'publication' AS category,
+                'quality_profile_publish' AS action,
+                version.status AS status,
+                version.published_by AS actor_uid,
+                'quality_profile' AS resource_type,
+                version.profile_uid::text AS resource_uid,
+                version.published_at AS occurred_at,
+                jsonb_build_object(
+                    'version', version.version,
+                    'content_hash', version.content_hash
+                ) AS safe_detail
+            FROM public.device_quality_profile_versions version
+            WHERE version.published_at IS NOT NULL
+        ) evidence
+        WHERE evidence.occurred_at >= :period_start
+          AND evidence.occurred_at <= :period_end
+    """,
+    "remediation": """
+        SELECT
+            'remediation:' || timeline.uid::text AS event_uid,
+            'remediation' AS category,
+            timeline.action AS action,
+            timeline.to_status AS status,
+            timeline.actor_uid::text AS actor_uid,
+            'quality_issue' AS resource_type,
+            timeline.issue_uid::text AS resource_uid,
+            timeline.created_at AS occurred_at,
+            jsonb_build_object(
+                'from_status', timeline.from_status,
+                'to_status', timeline.to_status
+            ) AS safe_detail
+        FROM public.device_quality_issue_timeline timeline
+        WHERE timeline.created_at >= :period_start
+          AND timeline.created_at <= :period_end
+    """,
+    "knowledge_query": """
+        SELECT
+            'knowledge-query:' || audit.id::text AS event_uid,
+            'knowledge_query' AS category,
+            'knowledge_query' AS action,
+            CASE
+                WHEN jsonb_array_length(audit.degraded_components) > 0
+                    THEN 'degraded'
+                ELSE 'success'
+            END AS status,
+            audit.user_id::text AS actor_uid,
+            'knowledge_query' AS resource_type,
+            audit.correlation_id::text AS resource_uid,
+            audit.created_at AS occurred_at,
+            jsonb_build_object(
+                'mode', audit.mode,
+                'citation_count', jsonb_array_length(audit.cited_points),
+                'degraded_count',
+                    jsonb_array_length(audit.degraded_components),
+                'latency_ms', audit.latency_ms
+            ) AS safe_detail
+        FROM public.knowledge_query_audits audit
+        WHERE audit.created_at >= :period_start
+          AND audit.created_at <= :period_end
+    """,
+}
+
+_SEAL_FIELDS = """
+    uid::text AS uid,
+    period_start,
+    period_end,
+    categories,
+    event_count,
+    root_hash,
+    signature,
+    key_version,
+    sealed_by,
+    created_at
+"""
+
+
+def _as_dict(row) -> dict[str, Any]:
+    return dict(row)
+
+
+def _iso(value):
+    if isinstance(value, datetime):
+        return value.isoformat().replace("+00:00", "Z")
+    return value
+
+
+def _seal(row) -> dict[str, Any]:
+    record = _as_dict(row)
+    record["period_start"] = _iso(record.get("period_start"))
+    record["period_end"] = _iso(record.get("period_end"))
+    record["created_at"] = _iso(record.get("created_at"))
+    record["categories"] = list(record.get("categories") or [])
+    record["event_count"] = int(record.get("event_count") or 0)
+    return record
+
+
+class SqlAlchemyGovernanceAuditRepository:
+    """Read safe audit projections and persist append-only evidence seals."""
+
+    def __init__(self, session):
+        self.session = session
+
+    def fetch_events(
+        self,
+        *,
+        categories,
+        period_start,
+        period_end,
+    ) -> list[dict[str, Any]]:
+        records = []
+        params = {
+            "period_start": period_start,
+            "period_end": period_end,
+        }
+        for category in categories:
+            query = _EVENT_QUERIES.get(category)
+            if query is None:
+                continue
+            rows = self.session.execute(text(query), params).mappings().all()
+            records.extend(_as_dict(row) for row in rows)
+        return records
+
+    def security_snapshot(self):
+        credential = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT
+                        COUNT(*) AS credential_count,
+                        COUNT(*) FILTER (
+                            WHERE octet_length(encrypted_payload) > 0
+                              AND octet_length(nonce) = 12
+                              AND NULLIF(BTRIM(key_version), '') IS NOT NULL
+                        ) AS encrypted_credential_count
+                    FROM public.datasource_credentials
+                    """
+                )
+            )
+            .mappings()
+            .one()
+        )
+        source = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT COUNT(*) AS plaintext_source_config_count
+                    FROM public.ingestion_sources
+                    WHERE jsonb_path_exists(
+                        config,
+                        '$.** ? (@.type() == "object").keyvalue() ? (
+                            @.key like_regex
+                            "^(password|passwd|credential|credentials|api_key|apikey|token|authorization|connection_string|connection_url|conn_str)$"
+                            flag "i"
+                        )'
+                    )
+                    """
+                )
+            )
+            .mappings()
+            .one()
+        )
+        return {
+            "credential_count": int(credential["credential_count"] or 0),
+            "encrypted_credential_count": int(
+                credential["encrypted_credential_count"] or 0
+            ),
+            "plaintext_source_config_count": int(
+                source["plaintext_source_config_count"] or 0
+            ),
+        }
+
+    def save_seal(self, seal):
+        row = (
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.governance_audit_seals (
+                        uid, period_start, period_end, categories,
+                        event_count, root_hash, signature, key_version,
+                        sealed_by
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:period_start AS timestamptz),
+                        CAST(:period_end AS timestamptz),
+                        CAST(:categories AS jsonb), :event_count,
+                        :root_hash, :signature, :key_version, :sealed_by
+                    )
+                    RETURNING
+                    """
+                    + _SEAL_FIELDS
+                ),
+                {
+                    **seal,
+                    "categories": json.dumps(
+                        list(seal["categories"]),
+                        ensure_ascii=False,
+                        separators=(",", ":"),
+                    ),
+                },
+            )
+            .mappings()
+            .one()
+        )
+        self.session.flush()
+        return _seal(row)
+
+    def get_seal(self, seal_uid):
+        row = (
+            self.session.execute(
+                text(
+                    "SELECT "
+                    + _SEAL_FIELDS
+                    + """
+                    FROM public.governance_audit_seals
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": seal_uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        return _seal(row) if row is not None else None
+
+    def list_seals(self, *, limit):
+        rows = (
+            self.session.execute(
+                text(
+                    "SELECT "
+                    + _SEAL_FIELDS
+                    + """
+                    FROM public.governance_audit_seals
+                    ORDER BY created_at DESC, uid DESC
+                    LIMIT :limit
+                    """
+                ),
+                {"limit": int(limit)},
+            )
+            .mappings()
+            .all()
+        )
+        return [_seal(row) for row in rows]

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

@@ -44,6 +44,8 @@ DEVICE_QUALITY_PUBLISH = "device-quality:publish"
 QUALITY_ISSUES_EDIT = "quality-issues:edit"
 QUALITY_ISSUES_REVIEW = "quality-issues:review"
 DEVICE_OBSERVABILITY_EDIT = "device-observability:edit"
+GOVERNANCE_AUDIT_READ = "governance-audit:read"
+GOVERNANCE_AUDIT_SEAL = "governance-audit:seal"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -110,6 +112,8 @@ ROLE_PERMISSIONS = {
             QUALITY_ISSUES_EDIT,
             QUALITY_ISSUES_REVIEW,
             DEVICE_OBSERVABILITY_EDIT,
+            GOVERNANCE_AUDIT_READ,
+            GOVERNANCE_AUDIT_SEAL,
         }
     ),
 }
@@ -126,6 +130,10 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if method == "GET":
             return (RESPONSIBILITIES_READ,)
         return (RESPONSIBILITIES_MANAGE,)
+    if path.startswith("/api/system/governance-audit"):
+        if method == "GET":
+            return (GOVERNANCE_AUDIT_READ,)
+        return (GOVERNANCE_AUDIT_SEAL,)
     if path in {"/api/knowledge/search", "/api/knowledge/ask"}:
         return (READ_GOVERNANCE,)
     if path.startswith("/api/rules"):

+ 4 - 0
deployment/dataops.env

@@ -7,6 +7,10 @@ SECRET_KEY=replace-with-a-long-random-secret
 # Dedicated HMAC key for short-lived AI rule generation receipts.
 # Generate independently with: openssl rand -hex 32
 RULE_GENERATION_RECEIPT_SECRET=replace-with-dedicated-64-hex-random-value
+# Dedicated HMAC key for governance audit evidence seals.
+# Generate independently with: openssl rand -hex 32
+AUDIT_EVIDENCE_SECRET=replace-with-dedicated-64-hex-random-value
+AUDIT_EVIDENCE_KEY_VERSION=v1
 
 # 平台 PostgreSQL(可与平台同机;部署前替换密码)
 DATABASE_URL=postgresql://dataops_user:replace-password@127.0.0.1:5432/dataops

+ 40 - 0
deployment/migrations/versions/20260730_360_governance_audit_seals.py

@@ -0,0 +1,40 @@
+"""Add append-only signed governance audit evidence seals."""
+
+from alembic import op
+
+revision = "20260730_360"
+down_revision = "20260729_350"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.governance_audit_seals (
+            uid UUID PRIMARY KEY,
+            period_start TIMESTAMPTZ NOT NULL,
+            period_end TIMESTAMPTZ NOT NULL,
+            categories JSONB NOT NULL,
+            event_count INTEGER NOT NULL CHECK (event_count >= 0),
+            root_hash CHAR(64) NOT NULL,
+            signature CHAR(64) NOT NULL,
+            key_version VARCHAR(80) NOT NULL,
+            sealed_by VARCHAR(160) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (period_end > period_start),
+            CHECK (jsonb_typeof(categories) = 'array')
+        );
+        CREATE INDEX idx_governance_audit_seals_created
+            ON public.governance_audit_seals(created_at DESC, uid);
+        CREATE INDEX idx_governance_audit_seals_period
+            ON public.governance_audit_seals(period_start, period_end);
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "governance audit evidence is append-only; "
+        "schema downgrade requires an approved archival migration"
+    )

+ 1 - 0
docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md

@@ -164,6 +164,7 @@ P2 不阻塞第一阶段验收。没有完成的 P2 功能必须保留接口和
 | WP-09 | 工程完成,待企业运行关系与专家验收 | 四类运行事件按来源身份不可变幂等接入;资产、事件和质量问题的八类有向证据关系;三跳、100 节点、200 边的有界关系图;只沿持久化上游证据关系返回根因候选和路径;证据不足时明确无法确认;编辑者导入与查看者只读分离;OpenAPI 195 项;真实 PostgreSQL 定向验证 | 需要企业接入告警、故障、维修和停机事件,确认关系方向、时间窗口和专家判定标准;当前不融合通用血缘与变更事件,不覆盖产品、报表、Agent 和业务域影响,不生成 AI 修复建议、自动修复、预测性维护或维修计划 |
 | WP-10 | 工程完成,待企业授权、模型与黄金集验收 | canonical 设备资产及四类运行事件进入现有混合检索;按设备名称、平台 UID、授权源 ID、位置、组织、责任人和事件检索;数据源业务域 SQL 预过滤与融合后二次授权;安全设备详情;授权证据问答、引用内容、模型不可用和证据不足拒答;最小化问题哈希审计;管理员源范围和审计工作台;十项版本化验收模板;OpenAPI 211 项;真实 PostgreSQL 与页面链路定向验证 | 需要企业配置真实设备源业务域范围,将十项模板绑定真实设备、故障和越权案例,在合规模型环境完成设备专家复核;未完成前不得声称企业验收或生产 K6;不建设 NL2SQL、在线分析、BI 开发、自动根因结论、自动修复或直接 LightRAG 回答 |
 | WP-11 | 工程完成,待企业口径、阈值与真实数据验收 | 实时计算台账完整率、责任覆盖率、实体映射率、问题闭环率和问题复发率;分子、分母、公式及零分母状态明确;按数据源业务域在 SQL 聚合前授权;跨域合并双端可见门禁;五项指标均可下钻到安全明细;固定只读运营看板;OpenAPI 213 项;真实 PostgreSQL 定向验证 | 需要企业确认必填字段、指标阈值、业务域范围和验收样本;当前只覆盖设备治理域,不保存手工快照或历史趋势,不建设综合评分、排名、成熟度、责任人绩效、通用 BI、NL2SQL 或分析开发能力;GOV-12、GOV-13 仍为规划中,PLT-03、PLT-04 成熟度不因固定看板提升 |
+| WP-12 | 工程完成,待企业安全、密钥与保留策略验收 | 数据源凭据密文状态和采集源明文秘密字段检查;全局异常及日志脱敏;登录、采集、实体合并/回滚、语义与质量发布、质量整改、知识问答六类安全审计投影;管理员专用审计工作台;规范化事件根摘要、HMAC-SHA256 签名封存与复核;单次 50,000 条失败关闭;OpenAPI 219 项;真实 PostgreSQL 篡改/回滚定向验证 | 需要企业安全负责人确认审计字段和抽样结果,配置独立生产封存密钥及版本,明确密钥保管/轮换和五年保留策略;当前签名封存用于检测篡改,不阻止数据库管理员修改,不包含外部可信时间戳、WORM/对象锁、SIEM、法务保全或自动归档;离线安装、备份、恢复和回滚归 WP-13 |
 
 ## 7. 12 周执行计划
 

+ 5 - 5
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -724,11 +724,11 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | SEC-05 | 数据安全 / 访问策略 | 用户、角色、业务域、行列、用途和环境策略 | 部分建设 |
 | SEC-06 | 数据安全 / 出域策略 | 原始数据不出内网、最小化、脱敏和审批 | 规划中 |
 | SEC-07 | 数据安全 / 数据使用 | 用途绑定、二次传播限制和到期回收 | 规划中 |
-| SEC-08 | 应用安全 / 秘密保护 | 凭据密文、日志脱敏、短期令牌和轮换 | 已建设 |
+| SEC-08 | 应用安全 / 秘密保护 | 凭据密文、全局异常与日志脱敏、短期令牌和轮换检查 | 已建设 |
 | SEC-09 | 应用安全 / 加密传输 | TLS、证书、内部服务认证和证书轮换 | 部分建设 |
-| SEC-10 | 应用安全 / 安全头 | CORS、安全响应头和错误边界 | 部分建设 |
-| SEC-11 | 安全审计 / 统一审计 | 登录、权限、数据、治理、发布、插件和 Agent 审计 | 部分建设 |
-| SEC-12 | 安全审计 / 防篡改 | 摘要、签名、时间戳、校验和归档证明 | 部分建设 |
+| SEC-10 | 应用安全 / 安全头 | 已形成 API 安全头和不回显异常的错误边界;生产 CORS 白名单、TLS 入口与集中策略仍待交付 | 部分建设 |
+| SEC-11 | 安全审计 / 统一审计 | 已汇总首期登录、采集、实体治理、发布、整改和问答六类安全记录;通用权限、插件和 Agent 全域审计仍待建设 | 部分建设 |
+| SEC-12 | 安全审计 / 防篡改 | 已形成事件根摘要、HMAC-SHA256 签名封存与复核;外部可信时间戳、不可变归档和法务证明仍待建设 | 部分建设 |
 | SEC-13 | 安全审计 / 证据保留 | 默认五年、按行业配置、关键记录永久归档 | 规划中 |
 | SEC-14 | 安全审计 / 法务保全 | 保全、冻结、导出、销毁审批和链路证明 | 规划中 |
 | SEC-15 | 安全集成 / SIEM | 内部安全中心以及 Syslog/Webhook 输出 | 规划中 |
@@ -804,7 +804,7 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | 17 | WFC-01、GOV-14 | 设备治理审核 | 设备映射、本体和故障分类由设备资产管理员最终审批 |
 | 18 | GOV-10、GOV-11 | 示范指标看板 | 展示元数据完整率、责任覆盖率、本体映射率和质量闭环率 |
 | 19 | PLT-03、PLT-04 | 示范工作台 | 打通服务端布局,展示设备资产、待审核、质量问题和知识状态 |
-| 20 | SEC-08、SEC-11 | 安全与审计 | 凭据不出接口,记录登录、采集、合并、审核、发布和问答审计 |
+| 20 | SEC-08、SEC-11、SEC-12 | 安全与审计 | 工程能力已形成;凭据不出接口,汇总登录、采集、合并/回滚、发布、整改和问答记录,并支持签名封存复核,待企业安全与密钥验收 |
 | 21 | PLT-05、PLT-25 | 企业内网交付 | 使用 Docker Compose 离线部署,提供安装、备份和恢复检查 |
 | 22 | CAT-21、SEM-10 | 版本与回滚 | 设备映射、本体和故障分类保留版本、差异和回滚能力 |
 

+ 33 - 0
docs/architecture/DATA_MODEL.md

@@ -229,6 +229,38 @@ canonical 数据计算,并返回固定定义、分子、分母、比率和可
 该投影只完成设备域固定运营视图,不代表综合业务域评分、员工绩效、排名趋势、成熟度驾驶舱、
 通用 BI、NL2SQL 或分析开发平台已经建设。
 
+## 4.2 WP12 安全审计查询投影与签名封存
+
+WP12 不复制一套通用审计日志,也不把自由文本和业务证据汇总到新的高敏感表。审计中心以
+现有 PostgreSQL 记录为源真相,只归一化以下六类第一阶段关键操作:
+
+| 类别代码 | 权威记录 | 安全投影 |
+|---|---|---|
+| `authentication` | `auth_audit_events` | 登录动作、成功/失败、用户身份和时间;不返回 IP、User-Agent 或详细错误 |
+| `ingestion` | `ingestion_jobs` | 采集类型、状态、操作者、数据源 UID、尝试次数和失败阶段;不返回参数、错误正文或源配置 |
+| `entity_resolution` | `device_entity_match_reviews`、`device_entity_merge_rollbacks` | 审批/拒绝/回滚、候选或合并 UID 和版本;不返回原因或快照 |
+| `publication` | `ontology_publish_runs`、`device_semantic_code_reviews`、`device_quality_profile_versions` | 本体、代码和质量策略发布/驳回状态与版本;不返回图文档、规则正文或审批原因 |
+| `remediation` | `device_quality_issue_timeline` | 状态动作、前后状态、问题 UID 和操作者;不返回备注、整改材料或证据正文 |
+| `knowledge_query` | `knowledge_query_audits` | 模式、引用/降级计数、关联 ID 和耗时;不返回问题原文、回答正文或访问网络标识 |
+
+管理员可按时间窗、类别、操作和状态查看最多每页 100 条安全记录。页面中的“暂无记录”只表示
+当前时间窗没有事件,不等于 0% 或审计能力缺失。查看与封存分别受
+`governance-audit:read`、`governance-audit:seal` 权限控制,均只授予管理员。
+
+`governance_audit_seals` 是追加式封存记录,保存时间窗、六类类别集合、事件数、根摘要、
+HMAC-SHA256 签名、密钥版本、封存人和创建时间。封存过程先对每条规范化安全事件生成
+SHA-256,再按稳定顺序生成根摘要,最后对封存元数据签名;复核时重新读取同一时间窗并计算。
+单次封存上限为 50,000 条,超过时失败关闭。结果分为 `intact`、`tampered` 和
+`invalid_signature`。封存结束时间不得晚于服务器当前时间,避免尚未闭合的时间窗因后续
+正常事件被误判为篡改;前端日期按用户本地日历转换,并将当天结束时间收敛到当前时刻。
+
+签名封存用于检测篡改,不等于阻止数据库管理员修改。生产环境必须配置至少 32 字节的独立
+`AUDIT_EVIDENCE_SECRET` 并记录 `AUDIT_EVIDENCE_KEY_VERSION`;本地工程环境允许使用由
+应用密钥派生的回退密钥,但安全检查明确标记为警告,不能据此通过企业安全验收。生产环境
+缺少独立密钥时,封存与复核接口失败关闭。采集源明文秘密检查递归扫描嵌套 JSON 配置键,
+不只检查顶层字段。五年保留目前是设计目标;定时归档、外部时间戳/不可变存储、法务保全
+以及备份恢复证明仍需企业制度和 WP13 交付流程补齐。
+
 ## 5. 所有权与删除规则
 
 - PostgreSQL 是身份、权限、映射、任务状态、布局和一致性事件的源真相。
@@ -244,6 +276,7 @@ canonical 数据计算,并返回固定定义、分子、分母、比率和可
 - 设备运行事件和有向证据关系以 PostgreSQL 为源真相;关系图是最多三跳、100 个节点和 200 条边的可重建查询投影。根因分析只沿 `indicates`、`triggered` 和 `evidences` 上游关系返回候选及证据路径;没有持久化路径时必须返回“证据不足,无法确认根因”,结果不触发自动修复或维修计划。
 - 设备知识检索直接读取授权后的 PostgreSQL canonical 资产、来源映射和运行事件;不建立第二份设备主数据,不读取来源配置和事件原始证据。问答不能替代 WP-09 的证据路径或设备专家根因结论。
 - 治理运营指标是 PostgreSQL canonical 数据的实时只读查询投影;不保存人工覆盖值。指标汇总与明细必须使用同一业务域授权边界,跨域合并只有两端均可见时才能计入非管理员结果。
+- 审计中心只读取六类现有权威记录的安全投影;`governance_audit_seals` 只追加封存摘要和签名,不接收原始问题、凭据、来源配置、自由文本备注或证据正文。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 134 - 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: 213
+x-route-count: 219
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -5182,6 +5182,139 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/governance-audit/coverage":
+    get:
+      tags: [system]
+      operationId: system_governance_audit_coverage_get
+      summary: "governance audit coverage"
+      x-source: "app/api/system/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/governance-audit/events":
+    get:
+      tags: [system]
+      operationId: system_governance_audit_events_get
+      summary: "governance audit events"
+      x-source: "app/api/system/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/governance-audit/seals":
+    get:
+      tags: [system]
+      operationId: system_governance_audit_seals_get
+      summary: "governance audit seals"
+      x-source: "app/api/system/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: [system]
+      operationId: system_create_governance_audit_seal_post
+      summary: "create governance audit seal"
+      x-source: "app/api/system/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/system/governance-audit/seals/{seal_uid}/verify":
+    post:
+      tags: [system]
+      operationId: system_verify_governance_audit_seal_post
+      summary: "verify governance audit seal"
+      x-source: "app/api/system/routes.py"
+      parameters:
+        - name: seal_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/governance-audit/security-checks":
+    get:
+      tags: [system]
+      operationId: system_governance_audit_security_checks_get
+      summary: "governance audit security checks"
+      x-source: "app/api/system/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/system/health":
     get:
       tags: [system]

+ 207 - 0
docs/superpowers/plans/2026-07-30-wp12-security-audit-evidence.md

@@ -0,0 +1,207 @@
+# WP12 Security, Audit, and Runtime Evidence 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:** Consolidate the phase-one device-governance security controls into an admin-only audit center that proves credential protection, shows six categories of key operational evidence, and detects later evidence tampering through signed seals.
+
+**Architecture:** Existing source tables remain the authoritative operational records. A read-only repository normalizes safe fields from login, ingestion, entity resolution, publication, remediation, and knowledge-query records; a domain service builds deterministic canonical hashes and HMAC-SHA256 seals without copying raw questions, credentials, evidence payloads, IP addresses, user agents, or free-form error text. A system-management API and Vue page expose security checks, coverage, bounded audit events, seal creation, and later verification.
+
+**Tech Stack:** Flask, SQLAlchemy text queries, PostgreSQL/Alembic, Python `hashlib`/`hmac`, Vue 2 + Vuetify, Node model tests, pytest.
+
+## Global Constraints
+
+- Work on `codex/dataops-phase1-equipment-governance`; do not push or deploy to production.
+- Run only WP12 and directly affected tests; do not run the full regression suite.
+- `app/` is authoritative and changed backend files must be copied byte-for-byte to `deployment/app/`.
+- Audit APIs are admin-only and must never return credentials, raw knowledge questions, IP addresses, user agents, free-form failure details, remediation notes, evidence payloads, or source configuration.
+- “Tamper protection” means signed tamper-evident sealing and later verification; it does not claim to prevent a database administrator from changing rows.
+- WP13 retains offline packaging, database backup, restore, migration rehearsal, and deployment rollback.
+
+---
+
+### Task 1: Canonical Audit Events and Signed Evidence Seals
+
+**Files:**
+- Create: `app/core/system/governance_audit.py`
+- Test: `tests/system/test_governance_audit.py`
+
+**Interfaces:**
+- Consumes: normalized dictionaries with `event_uid`, `category`, `action`, `status`, `actor_uid`, `resource_type`, `resource_uid`, `occurred_at`, and `safe_detail`.
+- Produces: `GovernanceAuditService.list_events(...)`, `GovernanceAuditService.coverage(...)`, `GovernanceAuditService.create_seal(...)`, and `GovernanceAuditService.verify_seal(...)`.
+
+- [x] **Step 1: Write failing domain tests**
+
+  Cover deterministic ordering and hashing, secret-like key rejection/redaction, six required coverage categories, bounded pagination/filter validation, HMAC signature generation, signature verification, and changed/missing-event detection.
+
+- [x] **Step 2: Run the domain tests and confirm RED**
+
+  Run: `PYTHONPATH=. .venv/bin/python -m pytest -q tests/system/test_governance_audit.py`
+
+  Expected: collection fails because `app.core.system.governance_audit` does not exist.
+
+- [x] **Step 3: Implement the minimum domain service**
+
+  Use canonical UTF-8 JSON with sorted keys and compact separators. Hash each normalized event with SHA-256, hash the ordered event digests into a root, and sign the seal payload with HMAC-SHA256. Reject secrets shorter than 32 bytes, unsupported categories, invalid ISO timestamps, `page_size > 100`, seal windows with `end <= start`, and source result sets larger than 50,000 events.
+
+- [x] **Step 4: Run the domain tests and confirm GREEN**
+
+  Run: `PYTHONPATH=. .venv/bin/python -m pytest -q tests/system/test_governance_audit.py`
+
+---
+
+### Task 2: PostgreSQL Repository and Evidence-Seal Persistence
+
+**Files:**
+- Create: `migrations/versions/20260730_360_governance_audit_seals.py`
+- Create: `app/core/system/governance_audit_repository.py`
+- Test: `tests/system/test_governance_audit_repository.py`
+- Test: `tests/integration/test_governance_audit_postgres.py`
+- Modify: `tests/test_database_migrations.py`
+
+**Interfaces:**
+- Consumes: the six fixed category names and an inclusive UTC time window.
+- Produces: `SqlAlchemyGovernanceAuditRepository.fetch_events(...)`, `category_counts(...)`, `save_seal(...)`, `get_seal(...)`, and `list_seals(...)`.
+
+- [x] **Step 1: Write failing repository and migration tests**
+
+  Assert safe, parameterized queries for:
+
+  - `auth_audit_events` → `authentication`;
+  - `ingestion_jobs` → `ingestion`;
+  - entity reviews and rollbacks → `entity_resolution`;
+  - ontology publish runs, semantic-code approvals, and quality-profile publications → `publication`;
+  - quality-issue timeline → `remediation`;
+  - `knowledge_query_audits` → `knowledge_query`.
+
+  Assert that returned detail is an explicit allowlist and SQL never selects raw question text, IP address, user agent, source config, credentials, remediation notes, evidence, or snapshots.
+
+- [x] **Step 2: Run the repository tests and confirm RED**
+
+  Run: `PYTHONPATH=. .venv/bin/python -m pytest -q tests/system/test_governance_audit_repository.py tests/test_database_migrations.py`
+
+- [x] **Step 3: Add the append-only seal table and repository**
+
+  Persist `uid`, `period_start`, `period_end`, `categories`, `event_count`, `root_hash`, `signature`, `key_version`, `sealed_by`, and `created_at`. The application exposes no update/delete path; downgrade retains evidence. Query each source independently with safe projections, normalize timestamps to UTC ISO-8601, merge deterministically, and apply filters/pagination after normalization.
+
+- [x] **Step 4: Run repository tests and a real PostgreSQL integration**
+
+  Run the repository tests, then run the integration test against the local migrated PostgreSQL database. The integration test must insert one safe fixture for every category, create a seal, verify it, mutate one fixture inside a transaction, observe verification failure, roll back, and verify again.
+
+---
+
+### Task 3: Admin-Only API, Safe Error Boundary, and Security Checks
+
+**Files:**
+- Create: `app/api/system/governance_audit.py`
+- Modify: `app/api/system/__init__.py`
+- Modify: `app/core/system/permissions.py`
+- Modify: `app/config/config.py`
+- Modify: `app/__init__.py`
+- Test: `tests/system/test_governance_audit_api.py`
+- Test: `tests/test_permission_matrix.py`
+- Test: `tests/system/test_safe_error_boundary.py`
+
+**Interfaces:**
+- Produces:
+  - `GET /api/system/governance-audit/security-checks`
+  - `GET /api/system/governance-audit/coverage`
+  - `GET /api/system/governance-audit/events`
+  - `GET /api/system/governance-audit/seals`
+  - `POST /api/system/governance-audit/seals`
+  - `POST /api/system/governance-audit/seals/{seal_uid}/verify`
+
+- [x] **Step 1: Write failing API, permission, and error-boundary tests**
+
+  Assert admin access, viewer/editor denial, server-derived actor identity, safe 400/503 responses, fixed categories, bounded query parameters, explicit “tamper-evident” wording, and that exceptions containing passwords, bearer tokens, database URLs, or API keys never appear in the JSON response or log message.
+
+- [x] **Step 2: Run tests and confirm RED**
+
+  Run: `PYTHONPATH=. .venv/bin/python -m pytest -q tests/system/test_governance_audit_api.py tests/system/test_safe_error_boundary.py tests/test_permission_matrix.py`
+
+- [x] **Step 3: Implement API and configuration**
+
+  Add `AUDIT_READ` and `AUDIT_SEAL` permissions to admin only. Load `AUDIT_EVIDENCE_SECRET` and `AUDIT_EVIDENCE_KEY_VERSION` without logging values; if the dedicated secret is absent, use `SECRET_KEY` only for local engineering and report a warning check. Security checks report booleans and reason codes for encrypted datasource storage, legacy plaintext cleanup, secret strength, secure error boundary, safe response headers, six-category audit availability, and seal-key readiness; they never expose configuration values.
+
+- [x] **Step 4: Harden the global error and response boundary**
+
+  Log a redacted bounded classification plus correlation ID, return only “服务器内部错误” and the correlation ID, and add `Referrer-Policy`, `Permissions-Policy`, and `Cache-Control: no-store` for authenticated/system audit responses. Preserve existing file-download behavior.
+
+- [x] **Step 5: Run API tests and confirm GREEN**
+
+  Re-run the tests from Step 2.
+
+---
+
+### Task 4: Audit and Runtime Evidence Workbench
+
+**Files:**
+- Create: `frontend/src/api/governanceAudit.js`
+- Create: `frontend/src/views/systemManage/governanceAudit/governanceAuditModel.js`
+- Create: `frontend/src/views/systemManage/governanceAudit/index.vue`
+- Create: `frontend/tests/governance-audit-model.test.mjs`
+- Modify: `frontend/src/router/routes.js`
+- Test: `tests/system/test_governance_audit_frontend_contract.py`
+
+**Interfaces:**
+- Consumes: the six system API endpoints from Task 3.
+- Produces: an admin-only “审计与运行证据” page with security check cards, six-category coverage, event filters/table, seal history, create-seal dialog, and verify action.
+
+- [x] **Step 1: Write failing frontend model and contract tests**
+
+  Assert fixed Chinese category labels, safe status labels, UTC/local time formatting, “签名封存用于检测篡改,不等于阻止数据库管理员修改” disclosure, no raw evidence/question/detail columns, admin-only route permission, and all six APIs.
+
+- [x] **Step 2: Run tests and confirm RED**
+
+  Run:
+
+  `node --test frontend/tests/governance-audit-model.test.mjs`
+
+  `PYTHONPATH=. .venv/bin/python -m pytest -q tests/system/test_governance_audit_frontend_contract.py`
+
+- [x] **Step 3: Implement the workbench**
+
+  Default to the last 30 days, page at 20 rows, display coverage as “有记录/暂无记录” rather than a false percentage, mark failed security checks clearly, disable sealing when the seal key is not ready, and show the stored root hash/signature only as shortened copyable identifiers.
+
+- [x] **Step 4: Run frontend tests, changed-file lint, and production build**
+
+  Run the Node test, Python contract test, ESLint only on changed frontend files, and the frontend production build.
+
+---
+
+### Task 5: Architecture, Ledger, Release-Copy Parity, and Targeted Acceptance
+
+**Files:**
+- Modify: `docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md`
+- Modify: `docs/FUNCTION_MODULE_CENSUS_20260726.md`
+- Modify: `docs/architecture/DATA_MODEL.md`
+- Modify: `docs/architecture/OPENAPI.yaml`
+- Create: `docs/validation/WP12_SECURITY_AUDIT_EVIDENCE.md`
+- Modify: `tests/test_architecture_artifacts.py`
+- Mirror changed backend files under: `deployment/app/`
+
+**Interfaces:**
+- Produces: a reviewable WP12 engineering receipt that distinguishes local engineering completion from enterprise security review and retention/real-data acceptance.
+
+- [x] **Step 1: Write failing architecture and parity assertions**
+
+  Assert all six endpoints, the seal table, SEC-08/SEC-10/SEC-11/SEC-12 status wording, the WP12 implementation-status row, and byte-for-byte parity for changed backend files.
+
+- [x] **Step 2: Run architecture tests and confirm RED**
+
+  Run: `PYTHONPATH=. .venv/bin/python -m pytest -q tests/test_architecture_artifacts.py tests/system/test_governance_audit_frontend_contract.py`
+
+- [x] **Step 3: Update documents and release copy**
+
+  Record the exact safe-field policy, six-category coverage, HMAC/key-rotation boundary, 50,000-event seal limit, five-year retention design target, current local test evidence, and the enterprise gates: security-owner approval, production secret/key custody, retention/archival job, real event sampling, and external backup/restore under WP13.
+
+- [x] **Step 4: Run targeted WP12 verification**
+
+  Run all new WP12 Python tests plus directly affected permission, migration, architecture, redaction, datasource-credential, and auth tests; run Ruff on changed Python files, the frontend Node test, changed-file ESLint, and frontend production build. Do not run the full suite.
+
+- [x] **Step 5: Run local browser acceptance**
+
+  Rebuild only local backend/frontend if necessary. As admin, verify the page loads, six categories are visible, no unsafe columns exist, creating a bounded seal succeeds, verifying it returns intact, requests return 200, and the browser console has no errors. Record actual evidence; do not substitute automated tests for browser checks.
+
+- [x] **Step 6: Re-read this plan and commit**
+
+  Confirm each requirement and boundary, ensure `git status` contains only WP12 changes, then commit locally with `feat: add tamper-evident governance audit`.

+ 100 - 0
docs/validation/WP12_SECURITY_AUDIT_EVIDENCE.md

@@ -0,0 +1,100 @@
+# WP12 安全、审计与运行证据验证记录
+
+## 1. 本次工程范围
+
+WP12 在现有凭据、身份、治理和问答记录之上完成安全收口,不新建通用日志平台:
+
+1. 管理员专用“审计与运行证据”工作台;
+2. 登录认证、数据采集、实体合并与回滚、语义与质量发布、质量整改、知识问答六类安全投影;
+3. 数据源凭据密文状态、采集源明文秘密字段、独立封存密钥、错误边界和安全响应头检查;
+4. 规范化事件 SHA-256 根摘要、HMAC-SHA256 签名封存与重新计算复核;
+5. 全局异常响应不回显内部错误,日志使用有界脱敏分类和关联 ID;
+6. `app/` 与 `deployment/app/` 的 WP12 变更文件保持一致。
+
+离线安装、数据库升级预检、备份、恢复和回滚演练仍属于 WP13。
+
+## 2. 安全字段边界
+
+审计 API 只返回时间、固定类别、操作代码、状态、操作者身份、对象类型/身份及少量枚举或
+计数字段。明确不返回:
+
+- 数据源凭据、加密载荷、Nonce、连接串或源配置;
+- 登录 IP、User-Agent 或认证详细错误;
+- 实体审核原因、合并/回滚快照;
+- 本体图文档、代码审批原因或质量规则正文;
+- 质量整改备注、材料、问题消息或证据正文;
+- 知识问题原文、回答正文或检索内容。
+
+六类投影查询均使用显式字段白名单;前端表格不提供上述字段列。
+
+## 3. 封存与复核规则
+
+- 先将安全事件按 UTC 时间和事件身份稳定排序;
+- 每条事件使用 canonical JSON 计算 SHA-256;
+- 有序事件摘要集合再次计算根摘要;
+- 时间窗、类别、事件数、根摘要、密钥版本和封存人使用 HMAC-SHA256 签名;
+- 单次最多 50,000 条,超过时失败关闭;
+- 复核状态为 `intact`、`tampered` 或 `invalid_signature`;
+- 封存结束时间不得晚于服务器当前时间,前端按本地日历并将当天窗口收敛到当前时刻;
+- 封存表只有新增和读取接口,不提供更新或删除接口。
+- 迁移降级不会静默保留表后回写旧版本号,而是失败关闭;如需降级,必须先走批准的归档迁移。
+
+签名封存用于检测篡改,不等于阻止数据库管理员修改。企业正式启用前必须配置独立
+`AUDIT_EVIDENCE_SECRET`(至少 32 字节)和 `AUDIT_EVIDENCE_KEY_VERSION`。本地回退密钥
+只用于工程验证,安全检查会显示警告;生产环境缺少独立密钥时封存与复核接口失败关闭。
+采集源明文秘密字段检查递归扫描嵌套 JSON 配置键。
+
+## 4. 定向验证
+
+本工作包只执行 WP12 及直接受影响范围的测试,不做全量回测。验证至少覆盖:
+
+- 规范化、分页、过滤、秘密字段递归脱敏和 50,000 条上限;
+- 根摘要稳定性、签名元数据被修改、事件变更/缺失检测;
+- 六类权威表安全查询和追加式封存表;
+- 管理员读取/封存权限,viewer/editor 拒绝;
+- ISO 时间窗、服务端封存人、安全 400/404/503;
+- 异常中数据库 URL、密码、API Key、Authorization/Bearer 不进入响应或日志;
+- 前端六类标签、“暂无记录”和三种复核状态;
+- 真实 PostgreSQL 六类事件、封存完整、事务内篡改失败、回滚后恢复完整;
+- OpenAPI、数据模型、台账、迁移和发布副本一致性;
+- 管理页实际请求、封存、复核和浏览器控制台。
+
+## 5. 企业验收门禁
+
+工程验证通过不能替代企业安全验收。WP12 只有在以下条件满足后才能标记企业验收:
+
+1. 企业安全负责人确认六类审计字段、抽样记录和访问人员;
+2. 生产环境配置独立封存密钥,明确密钥保管、轮换、遗失和旧版本复核流程;
+3. 明确五年保留目标、归档位置、清理审批和合规例外;
+4. 使用真实登录、采集、合并/回滚、发布、整改和问答各抽样至少一条;
+5. WP13 完成备份、恢复和升级回滚演练后,证明封存记录可随系统恢复;
+6. 如需要监管级证明,另行建设外部可信时间戳、WORM/对象锁、SIEM 和法务保全。
+
+## 6. 本地工程验证结果(2026-07-30)
+
+本轮按 WP12 变更范围完成定向验证,没有执行全量回归:
+
+- WP12 后端、权限、迁移、架构、凭据、脱敏、认证和前端契约受影响测试共 52 项通过;
+- 前端模型测试 5 项通过;
+- WP12 新增 Python 文件及本次触达运行代码 Ruff 检查通过;迁移测试文件中一处未改动的
+  既有 `B905` 告警仍保留;新增前端文件 ESLint 检查通过;
+- 前端生产构建成功;构建仍报告项目既有的 Browserslist 数据过期、旧页面
+  `console`、样式顺序和包体积告警,本次新增页面没有检查错误;
+- Docker Compose 配置校验通过,本地 backend/frontend 重建后均为 healthy;
+- Alembic 已在本地测试库升级到 `360`,真实 PostgreSQL 集成测试验证六类事件、
+  封存完整、事务内修改可检测、回滚后恢复完整;
+- OpenAPI 已重新生成,共 219 个操作,并包含 WP12 六个接口;
+- `app/` 与 `deployment/app/` 的 WP12 后端文件及迁移文件逐字节一致。
+
+浏览器以管理员身份访问本地“审计与运行证据”页面,确认:
+
+1. 六类覆盖卡片均可见;当前 30 日窗口内登录认证 34 条、数据采集 1 条、
+   实体合并与回滚 2 条、语义与质量发布 2 条、质量整改 5 条、知识问答 0 条;
+2. 0 条类别明确显示“暂无记录”,没有误报为能力缺失;
+3. 安全检查显示 1 个数据源凭据已密文保存、0 个采集源明文秘密字段;
+4. 修正本地日期与未来时间窗边界后,创建了包含 44 条规范化事件的闭合时间窗本地封存,
+   密钥版本为 `local-v1`;
+5. 点击“重新校验”后返回“校验完整”;
+6. 页面只显示安全白名单字段,浏览器控制台没有错误。
+
+上述封存使用本地工程密钥,不代表生产密钥已交付。企业验收仍以第 5 节门禁为准。

+ 28 - 0
frontend/src/api/governanceAudit.js

@@ -0,0 +1,28 @@
+import http from '@/utils/request'
+
+export function getGovernanceAuditSecurityChecks () {
+  return http.get('/system/governance-audit/security-checks')
+}
+
+export function getGovernanceAuditCoverage (params) {
+  return http.get('/system/governance-audit/coverage', params)
+}
+
+export function getGovernanceAuditEvents (params) {
+  return http.get('/system/governance-audit/events', params)
+}
+
+export function getGovernanceAuditSeals (limit = 50) {
+  return http.get('/system/governance-audit/seals', { limit })
+}
+
+export function createGovernanceAuditSeal (payload) {
+  return http.post('/system/governance-audit/seals', payload)
+}
+
+export function verifyGovernanceAuditSeal (sealUid) {
+  return http.post(
+    `/system/governance-audit/seals/${encodeURIComponent(sealUid)}/verify`,
+    {}
+  )
+}

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

@@ -1747,6 +1747,23 @@ export default {
           },
           name: 'systemResponsibilityManage',
           alwaysShow: 0
+        },
+        {
+          hidden: 0,
+          type: 1,
+          title: '审计与运行证据',
+          path: '/systemManage/governance-audit',
+          children: [],
+          label: '审计与运行证据',
+          sort: 3,
+          component: 'systemManage/governanceAudit',
+          meta: {
+            title: '审计与运行证据',
+            icon: 'mdi-shield-search',
+            permissions: ['governance-audit:read', 'governance-audit:seal']
+          },
+          name: 'systemGovernanceAudit',
+          alwaysShow: 0
         }
       ],
       label: '系统管理',

+ 148 - 0
frontend/src/views/systemManage/governanceAudit/governanceAuditModel.js

@@ -0,0 +1,148 @@
+const CATEGORIES = {
+  authentication: {
+    label: '登录认证',
+    icon: 'mdi-login-variant',
+    color: 'indigo'
+  },
+  ingestion: {
+    label: '数据采集',
+    icon: 'mdi-database-import-outline',
+    color: 'blue'
+  },
+  entity_resolution: {
+    label: '实体合并与回滚',
+    icon: 'mdi-vector-link',
+    color: 'deep-purple'
+  },
+  publication: {
+    label: '语义与质量发布',
+    icon: 'mdi-publish',
+    color: 'teal'
+  },
+  remediation: {
+    label: '质量整改',
+    icon: 'mdi-clipboard-check-outline',
+    color: 'orange'
+  },
+  knowledge_query: {
+    label: '知识问答',
+    icon: 'mdi-message-text-lock-outline',
+    color: 'cyan'
+  }
+}
+
+const STATUSES = {
+  success: { label: '成功', color: 'success' },
+  failed: { label: '失败', color: 'error' },
+  degraded: { label: '降级完成', color: 'warning' },
+  published: { label: '已发布', color: 'success' },
+  approve: { label: '已批准', color: 'success' },
+  reject: { label: '已拒绝', color: 'error' },
+  closed: { label: '已关闭', color: 'success' },
+  pending_review: { label: '待复核', color: 'warning' }
+}
+
+const INTEGRITY = {
+  intact: { label: '校验完整', color: 'success', icon: 'mdi-shield-check' },
+  tampered: { label: '检测到变更', color: 'error', icon: 'mdi-shield-alert' },
+  invalid_signature: {
+    label: '封存签名无效',
+    color: 'error',
+    icon: 'mdi-shield-remove'
+  }
+}
+
+export const auditCategoryOptions = Object.entries(CATEGORIES).map(
+  ([value, presentation]) => ({
+    value,
+    text: presentation.label
+  })
+)
+
+export function categoryPresentation (code) {
+  return CATEGORIES[code] || {
+    label: code || '未知类别',
+    icon: 'mdi-file-question-outline',
+    color: 'blue-grey'
+  }
+}
+
+export function statusPresentation (status) {
+  return STATUSES[status] || {
+    label: status || '未知状态',
+    color: 'blue-grey'
+  }
+}
+
+export function coveragePresentation (item) {
+  return Number(item?.count || 0) > 0
+    ? { label: '有记录', color: 'success' }
+    : { label: '暂无记录', color: 'blue-grey' }
+}
+
+export function integrityPresentation (status) {
+  return INTEGRITY[status] || {
+    label: status ? `未识别:${status}` : '尚未校验',
+    color: 'blue-grey',
+    icon: 'mdi-shield-search'
+  }
+}
+
+export function formatAuditTime (value) {
+  if (!value) return '-'
+  const parsed = new Date(value)
+  if (Number.isNaN(parsed.getTime())) return '-'
+  return parsed.toLocaleString('zh-CN', { hour12: false })
+}
+
+export function shortDigest (value) {
+  if (!value) return '-'
+  const normalized = String(value)
+  if (normalized.length <= 24) return normalized
+  return `${normalized.slice(0, 12)}…${normalized.slice(-8)}`
+}
+
+export function auditEventHeaders () {
+  return [
+    { text: '发生时间', value: 'occurred_at', width: 176 },
+    { text: '类别', value: 'category', width: 150, sortable: false },
+    { text: '操作', value: 'action', width: 180, sortable: false },
+    { text: '状态', value: 'status', width: 116, sortable: false },
+    { text: '操作者', value: 'actor_uid', width: 190, sortable: false },
+    { text: '对象', value: 'resource', sortable: false }
+  ]
+}
+
+function dateText (date) {
+  const year = date.getFullYear()
+  const month = String(date.getMonth() + 1).padStart(2, '0')
+  const day = String(date.getDate()).padStart(2, '0')
+  return `${year}-${month}-${day}`
+}
+
+export function defaultAuditWindow (now = new Date()) {
+  const end = new Date(now)
+  const start = new Date(now)
+  start.setDate(start.getDate() - 30)
+  return {
+    startDate: dateText(start),
+    endDate: dateText(end)
+  }
+}
+
+function localDate (value, endOfDay = false) {
+  const [year, month, day] = value.split('-').map(Number)
+  return endOfDay
+    ? new Date(year, month - 1, day, 23, 59, 59, 999)
+    : new Date(year, month - 1, day, 0, 0, 0, 0)
+}
+
+export function toAuditWindow (startDate, endDate, now = new Date()) {
+  const start = localDate(startDate)
+  const requestedEnd = localDate(endDate, true)
+  const boundedEnd = requestedEnd > now ? new Date(now) : requestedEnd
+  return {
+    period_start: start.toISOString(),
+    period_end: boundedEnd.toISOString()
+  }
+}

+ 586 - 0
frontend/src/views/systemManage/governanceAudit/index.vue

@@ -0,0 +1,586 @@
+<template>
+  <v-container fluid class="audit-page pa-6">
+    <div class="audit-hero mb-6">
+      <div>
+        <div class="overline audit-kicker">SECURITY &amp; OPERATIONS EVIDENCE</div>
+        <h1 class="text-h4 font-weight-bold mb-2">审计与运行证据</h1>
+        <p class="audit-subtitle mb-0">
+          汇总登录、采集、实体治理、发布、整改和知识问答记录,形成可复核的安全运营证据。
+        </p>
+      </div>
+      <v-btn color="primary" depressed :loading="loading" @click="loadAll">
+        <v-icon left>mdi-refresh</v-icon>
+        刷新证据
+      </v-btn>
+    </div>
+
+    <v-alert
+      outlined
+      color="indigo"
+      icon="mdi-shield-lock-outline"
+      class="assurance-alert mb-6"
+    >
+      <strong>边界说明:</strong>
+      签名封存用于检测篡改,不等于阻止数据库管理员修改。生产环境还需配置独立密钥,
+      并由企业完成密钥保管、轮换和归档制度验收。
+    </v-alert>
+
+    <section class="mb-7">
+      <div class="section-heading">
+        <div>
+          <div class="section-eyebrow">POSTURE</div>
+          <h2 class="text-h5 mb-1">安全检查</h2>
+        </div>
+        <v-chip
+          small
+          outlined
+          :color="security.production_key_ready ? 'success' : 'warning'"
+        >
+          {{ security.production_key_ready ? '独立封存密钥已配置' : '本地回退密钥' }}
+        </v-chip>
+      </div>
+      <v-row>
+        <v-col
+          v-for="check in security.checks || []"
+          :key="check.code"
+          cols="12"
+          sm="6"
+          lg="4"
+        >
+          <v-card outlined class="check-card fill-height">
+            <v-card-text>
+              <div class="d-flex align-start justify-space-between">
+                <div class="check-icon">
+                  <v-icon :color="checkColor(check.status)">
+                    {{ checkIcon(check.status) }}
+                  </v-icon>
+                </div>
+                <v-chip x-small :color="checkColor(check.status)" dark>
+                  {{ checkStatus(check.status) }}
+                </v-chip>
+              </div>
+              <div class="subtitle-1 font-weight-medium mt-4">{{ check.name }}</div>
+              <div v-if="check.observed_count !== undefined" class="caption mt-2">
+                当前 {{ check.observed_count }} / 期望 {{ check.expected_count }}
+              </div>
+              <div v-if="check.reason_code" class="caption mt-2 text--secondary">
+                {{ check.reason_code }}
+              </div>
+            </v-card-text>
+          </v-card>
+        </v-col>
+      </v-row>
+    </section>
+
+    <v-card outlined class="evidence-card mb-7">
+      <v-card-title class="section-heading px-6 pt-6">
+        <div>
+          <div class="section-eyebrow">COVERAGE</div>
+          <h2 class="text-h5 mb-1">关键操作覆盖</h2>
+          <div class="text-body-2 text--secondary">
+            “暂无记录”表示当前时间窗没有事件,不代表审计能力缺失。
+          </div>
+        </div>
+        <div class="date-controls">
+          <v-text-field
+            v-model="startDate"
+            type="date"
+            label="开始日期"
+            dense
+            outlined
+            hide-details
+          />
+          <v-text-field
+            v-model="endDate"
+            type="date"
+            label="结束日期"
+            dense
+            outlined
+            hide-details
+          />
+          <v-btn outlined color="primary" @click="applyWindow">应用</v-btn>
+        </div>
+      </v-card-title>
+      <v-card-text class="px-6 pb-6">
+        <v-row>
+          <v-col
+            v-for="item in coverage.categories || []"
+            :key="item.category"
+            cols="12"
+            sm="6"
+            lg="2"
+          >
+            <div class="coverage-tile">
+              <v-icon
+                size="28"
+                :color="category(item.category).color"
+                class="mb-3"
+              >
+                {{ category(item.category).icon }}
+              </v-icon>
+              <div class="font-weight-medium">{{ category(item.category).label }}</div>
+              <div class="coverage-count">{{ item.count || 0 }}</div>
+              <v-chip
+                x-small
+                outlined
+                :color="coverageState(item).color"
+              >
+                {{ coverageState(item).label }}
+              </v-chip>
+              <div class="coverage-time">
+                最近:{{ formatTime(item.latest_at) }}
+              </div>
+            </div>
+          </v-col>
+        </v-row>
+      </v-card-text>
+    </v-card>
+
+    <v-card outlined class="evidence-card mb-7">
+      <v-card-title class="section-heading px-6 pt-6">
+        <div>
+          <div class="section-eyebrow">EVENT STREAM</div>
+          <h2 class="text-h5 mb-1">审计事件</h2>
+          <div class="text-body-2 text--secondary">
+            仅展示安全运营字段,不展示问题原文、凭据、网络标识或业务证据正文。
+          </div>
+        </div>
+      </v-card-title>
+      <v-card-text class="px-6">
+        <v-row dense>
+          <v-col cols="12" md="4">
+            <v-select
+              v-model="selectedCategories"
+              :items="categoryOptions"
+              label="操作类别"
+              multiple
+              chips
+              small-chips
+              clearable
+              dense
+              outlined
+              hide-details
+            />
+          </v-col>
+          <v-col cols="12" md="3">
+            <v-text-field
+              v-model.trim="actionFilter"
+              label="操作代码"
+              dense
+              outlined
+              clearable
+              hide-details
+            />
+          </v-col>
+          <v-col cols="12" md="3">
+            <v-text-field
+              v-model.trim="statusFilter"
+              label="状态代码"
+              dense
+              outlined
+              clearable
+              hide-details
+            />
+          </v-col>
+          <v-col cols="12" md="2">
+            <v-btn block color="primary" outlined height="40" @click="searchEvents">
+              查询
+            </v-btn>
+          </v-col>
+        </v-row>
+      </v-card-text>
+      <v-data-table
+        :headers="eventHeaders"
+        :items="events"
+        :loading="loadingEvents"
+        :server-items-length="eventTotal"
+        :page.sync="eventPage"
+        :items-per-page="20"
+        class="audit-table"
+        @update:page="loadEvents"
+      >
+        <template v-slot:[`item.occurred_at`]="{ item }">
+          <span class="mono-cell">{{ formatTime(item.occurred_at) }}</span>
+        </template>
+        <template v-slot:[`item.category`]="{ item }">
+          <v-chip small outlined :color="category(item.category).color">
+            {{ category(item.category).label }}
+          </v-chip>
+        </template>
+        <template v-slot:[`item.action`]="{ item }">
+          <span class="mono-cell">{{ item.action }}</span>
+        </template>
+        <template v-slot:[`item.status`]="{ item }">
+          <v-chip x-small :color="status(item.status).color" dark>
+            {{ status(item.status).label }}
+          </v-chip>
+        </template>
+        <template v-slot:[`item.actor_uid`]="{ item }">
+          <span class="mono-cell">{{ item.actor_uid || '-' }}</span>
+        </template>
+        <template v-slot:[`item.resource`]="{ item }">
+          <div>{{ item.resource_type }}</div>
+          <div class="caption mono-cell text--secondary">
+            {{ item.resource_uid || '-' }}
+          </div>
+        </template>
+      </v-data-table>
+    </v-card>
+
+    <v-card outlined class="evidence-card">
+      <v-card-title class="section-heading px-6 pt-6">
+        <div>
+          <div class="section-eyebrow">TAMPER EVIDENCE</div>
+          <h2 class="text-h5 mb-1">证据封存</h2>
+          <div class="text-body-2 text--secondary">
+            对当前时间窗内的规范化事件计算根摘要并签名,后续可重新计算校验。
+          </div>
+        </div>
+        <v-btn
+          color="primary"
+          depressed
+          :loading="sealing"
+          :disabled="!security.seal_ready"
+          @click="createSeal"
+        >
+          <v-icon left>mdi-shield-plus-outline</v-icon>
+          封存当前时间窗
+        </v-btn>
+      </v-card-title>
+      <v-data-table
+        :headers="sealHeaders"
+        :items="seals"
+        :loading="loadingSeals"
+        :items-per-page="10"
+        class="audit-table"
+      >
+        <template v-slot:[`item.period`]="{ item }">
+          <div>{{ formatTime(item.period_start) }}</div>
+          <div class="caption text--secondary">至 {{ formatTime(item.period_end) }}</div>
+        </template>
+        <template v-slot:[`item.event_count`]="{ item }">
+          {{ item.event_count }} 条
+        </template>
+        <template v-slot:[`item.root_hash`]="{ item }">
+          <span class="mono-cell">{{ digest(item.root_hash) }}</span>
+        </template>
+        <template v-slot:[`item.signature`]="{ item }">
+          <span class="mono-cell">{{ digest(item.signature) }}</span>
+        </template>
+        <template v-slot:[`item.integrity_status`]="{ item }">
+          <v-chip
+            v-if="item.integrity_status"
+            small
+            outlined
+            :color="integrity(item.integrity_status).color"
+          >
+            <v-icon left small>{{ integrity(item.integrity_status).icon }}</v-icon>
+            {{ integrity(item.integrity_status).label }}
+          </v-chip>
+          <span v-else class="text--secondary">尚未校验</span>
+        </template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <v-btn
+            text
+            small
+            color="primary"
+            :loading="verifyingUid === item.uid"
+            @click="verifySeal(item)"
+          >
+            重新校验
+          </v-btn>
+        </template>
+      </v-data-table>
+    </v-card>
+  </v-container>
+</template>
+
+<script>
+import {
+  createGovernanceAuditSeal,
+  getGovernanceAuditCoverage,
+  getGovernanceAuditEvents,
+  getGovernanceAuditSeals,
+  getGovernanceAuditSecurityChecks,
+  verifyGovernanceAuditSeal
+} from '@/api/governanceAudit'
+import {
+  auditCategoryOptions,
+  auditEventHeaders,
+  categoryPresentation,
+  coveragePresentation,
+  defaultAuditWindow,
+  formatAuditTime,
+  integrityPresentation,
+  shortDigest,
+  statusPresentation,
+  toAuditWindow
+} from './governanceAuditModel'
+
+const initialWindow = defaultAuditWindow()
+
+export default {
+  name: 'GovernanceAuditWorkbench',
+  data: () => ({
+    loading: false,
+    loadingEvents: false,
+    loadingSeals: false,
+    sealing: false,
+    verifyingUid: null,
+    security: { checks: [], seal_ready: false, production_key_ready: false },
+    coverage: { categories: [] },
+    events: [],
+    eventTotal: 0,
+    eventPage: 1,
+    seals: [],
+    startDate: initialWindow.startDate,
+    endDate: initialWindow.endDate,
+    selectedCategories: [],
+    actionFilter: '',
+    statusFilter: '',
+    categoryOptions: auditCategoryOptions,
+    eventHeaders: auditEventHeaders(),
+    sealHeaders: [
+      { text: '时间窗', value: 'period', sortable: false },
+      { text: '事件数', value: 'event_count', sortable: false },
+      { text: '根摘要', value: 'root_hash', sortable: false },
+      { text: '签名', value: 'signature', sortable: false },
+      { text: '密钥版本', value: 'key_version', sortable: false },
+      { text: '校验状态', value: 'integrity_status', sortable: false },
+      { text: '操作', value: 'actions', sortable: false }
+    ]
+  }),
+  created () {
+    this.loadAll()
+  },
+  methods: {
+    category: categoryPresentation,
+    coverageState: coveragePresentation,
+    formatTime: formatAuditTime,
+    integrity: integrityPresentation,
+    digest: shortDigest,
+    status: statusPresentation,
+    windowParams () {
+      return toAuditWindow(this.startDate, this.endDate)
+    },
+    checkColor (state) {
+      return { passed: 'success', warning: 'warning', failed: 'error' }[state] || 'blue-grey'
+    },
+    checkIcon (state) {
+      return {
+        passed: 'mdi-check-circle-outline',
+        warning: 'mdi-alert-circle-outline',
+        failed: 'mdi-close-circle-outline'
+      }[state] || 'mdi-help-circle-outline'
+    },
+    checkStatus (state) {
+      return { passed: '通过', warning: '需关注', failed: '未通过' }[state] || state
+    },
+    async loadAll () {
+      this.loading = true
+      try {
+        await Promise.all([
+          this.loadSecurity(),
+          this.loadCoverage(),
+          this.loadEvents(),
+          this.loadSeals()
+        ])
+      } finally {
+        this.loading = false
+      }
+    },
+    async loadSecurity () {
+      try {
+        const response = await getGovernanceAuditSecurityChecks()
+        this.security = response.data || this.security
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '安全检查加载失败')
+      }
+    },
+    async loadCoverage () {
+      try {
+        const response = await getGovernanceAuditCoverage(this.windowParams())
+        this.coverage = response.data || { categories: [] }
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '审计覆盖加载失败')
+      }
+    },
+    async loadEvents () {
+      this.loadingEvents = true
+      try {
+        const response = await getGovernanceAuditEvents({
+          ...this.windowParams(),
+          category: this.selectedCategories,
+          action: this.actionFilter || undefined,
+          status: this.statusFilter || undefined,
+          page: this.eventPage,
+          page_size: 20
+        })
+        this.events = response.data.records || []
+        this.eventTotal = Number(response.data.total || 0)
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '审计事件加载失败')
+      } finally {
+        this.loadingEvents = false
+      }
+    },
+    async loadSeals () {
+      this.loadingSeals = true
+      try {
+        const response = await getGovernanceAuditSeals(50)
+        const prior = new Map(this.seals.map(item => [item.uid, item.integrity_status]))
+        this.seals = (response.data || []).map(item => ({
+          ...item,
+          integrity_status: prior.get(item.uid) || null
+        }))
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '证据封存加载失败')
+      } finally {
+        this.loadingSeals = false
+      }
+    },
+    async applyWindow () {
+      this.eventPage = 1
+      await Promise.all([this.loadCoverage(), this.loadEvents()])
+    },
+    searchEvents () {
+      this.eventPage = 1
+      return this.loadEvents()
+    },
+    async createSeal () {
+      this.sealing = true
+      try {
+        await createGovernanceAuditSeal({
+          ...this.windowParams(),
+          categories: this.selectedCategories.length
+            ? this.selectedCategories
+            : undefined
+        })
+        this.$snackbar.success('当前时间窗已完成签名封存')
+        await this.loadSeals()
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '证据封存失败')
+      } finally {
+        this.sealing = false
+      }
+    },
+    async verifySeal (item) {
+      this.verifyingUid = item.uid
+      try {
+        const response = await verifyGovernanceAuditSeal(item.uid)
+        this.$set(item, 'integrity_status', response.data.integrity_status)
+        const state = integrityPresentation(response.data.integrity_status)
+        const notify = response.data.integrity_status === 'intact' ? 'success' : 'error'
+        this.$snackbar[notify](state.label)
+      } catch (error) {
+        this.$snackbar.error(error?.message || error || '封存校验失败')
+      } finally {
+        this.verifyingUid = null
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.audit-page {
+  max-width: 1680px;
+  margin: 0 auto;
+  color: #17233d;
+}
+
+.audit-hero,
+.section-heading {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 24px;
+}
+
+.audit-kicker,
+.section-eyebrow {
+  letter-spacing: .16em;
+  font-weight: 700;
+  color: #4f46a5;
+}
+
+.audit-subtitle {
+  color: #5f6b85;
+  max-width: 780px;
+}
+
+.assurance-alert {
+  background: linear-gradient(100deg, rgba(79, 70, 229, .05), rgba(14, 116, 144, .04));
+}
+
+.check-card,
+.evidence-card {
+  border-color: #dce2ee !important;
+  border-radius: 14px !important;
+  box-shadow: 0 8px 26px rgba(31, 45, 79, .04);
+}
+
+.check-icon {
+  display: grid;
+  place-items: center;
+  width: 42px;
+  height: 42px;
+  border-radius: 12px;
+  background: #f3f5fa;
+}
+
+.date-controls {
+  display: grid;
+  grid-template-columns: 170px 170px auto;
+  gap: 10px;
+  align-items: center;
+}
+
+.coverage-tile {
+  min-height: 190px;
+  padding: 18px;
+  border: 1px solid #e3e7ef;
+  border-radius: 12px;
+  background: linear-gradient(150deg, #fff, #f8f9fc);
+}
+
+.coverage-count {
+  margin: 8px 0 4px;
+  font-size: 1.8rem;
+  font-weight: 750;
+  color: #17233d;
+}
+
+.coverage-time {
+  margin-top: 14px;
+  color: #7a849a;
+  font-size: .72rem;
+  line-height: 1.4;
+}
+
+.mono-cell {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: .78rem;
+  word-break: break-all;
+}
+
+.audit-table {
+  border-top: 1px solid #eef1f6;
+}
+
+@media (max-width: 960px) {
+  .audit-hero,
+  .section-heading {
+    flex-direction: column;
+  }
+
+  .date-controls {
+    width: 100%;
+    grid-template-columns: 1fr 1fr;
+  }
+
+  .date-controls .v-btn {
+    grid-column: 1 / -1;
+  }
+}
+</style>

+ 99 - 0
frontend/tests/governance-audit-model.test.mjs

@@ -0,0 +1,99 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import {
+  auditEventHeaders,
+  categoryPresentation,
+  coveragePresentation,
+  defaultAuditWindow,
+  formatAuditTime,
+  integrityPresentation,
+  shortDigest,
+  statusPresentation,
+  toAuditWindow
+} from '../src/views/systemManage/governanceAudit/governanceAuditModel.js'
+
+test('presents the six fixed governance audit categories', () => {
+  const labels = [
+    'authentication',
+    'ingestion',
+    'entity_resolution',
+    'publication',
+    'remediation',
+    'knowledge_query'
+  ].map(code => categoryPresentation(code).label)
+
+  assert.deepEqual(labels, [
+    '登录认证',
+    '数据采集',
+    '实体合并与回滚',
+    '语义与质量发布',
+    '质量整改',
+    '知识问答'
+  ])
+})
+
+test('keeps no records distinct and presents seal integrity explicitly', () => {
+  assert.deepEqual(coveragePresentation({ count: 0 }), {
+    label: '暂无记录',
+    color: 'blue-grey'
+  })
+  assert.deepEqual(coveragePresentation({ count: 2 }), {
+    label: '有记录',
+    color: 'success'
+  })
+  assert.equal(integrityPresentation('intact').label, '校验完整')
+  assert.equal(integrityPresentation('tampered').label, '检测到变更')
+  assert.equal(
+    integrityPresentation('invalid_signature').label,
+    '封存签名无效'
+  )
+})
+
+test('formats only bounded safe event fields', () => {
+  const values = auditEventHeaders().map(item => item.value)
+  assert.deepEqual(values, [
+    'occurred_at',
+    'category',
+    'action',
+    'status',
+    'actor_uid',
+    'resource'
+  ])
+  for (const forbidden of [
+    'password',
+    'query',
+    'evidence',
+    'ip_address',
+    'user_agent',
+    'note',
+    'payload'
+  ]) {
+    assert.ok(!values.includes(forbidden))
+  }
+  assert.equal(statusPresentation('failed').color, 'error')
+  assert.equal(formatAuditTime(null), '-')
+  assert.match(formatAuditTime('2026-07-30T00:00:00Z'), /2026/)
+})
+
+test('shortens hashes without mistaking empty values for evidence', () => {
+  assert.equal(shortDigest(null), '-')
+  assert.equal(shortDigest('a'.repeat(64)), 'aaaaaaaaaaaa…aaaaaaaa')
+})
+
+test('uses local calendar dates and never seals a future window', () => {
+  const now = new Date(2026, 6, 30, 1, 30, 0, 0)
+
+  assert.deepEqual(defaultAuditWindow(now), {
+    startDate: '2026-06-30',
+    endDate: '2026-07-30'
+  })
+  assert.deepEqual(toAuditWindow('2026-07-01', '2026-07-30', now), {
+    period_start: new Date(2026, 6, 1, 0, 0, 0, 0).toISOString(),
+    period_end: now.toISOString()
+  })
+  assert.equal(
+    toAuditWindow('2026-07-01', '2026-07-29', now).period_end,
+    new Date(2026, 6, 29, 23, 59, 59, 999).toISOString()
+  )
+})

+ 40 - 0
migrations/versions/20260730_360_governance_audit_seals.py

@@ -0,0 +1,40 @@
+"""Add append-only signed governance audit evidence seals."""
+
+from alembic import op
+
+revision = "20260730_360"
+down_revision = "20260729_350"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.governance_audit_seals (
+            uid UUID PRIMARY KEY,
+            period_start TIMESTAMPTZ NOT NULL,
+            period_end TIMESTAMPTZ NOT NULL,
+            categories JSONB NOT NULL,
+            event_count INTEGER NOT NULL CHECK (event_count >= 0),
+            root_hash CHAR(64) NOT NULL,
+            signature CHAR(64) NOT NULL,
+            key_version VARCHAR(80) NOT NULL,
+            sealed_by VARCHAR(160) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (period_end > period_start),
+            CHECK (jsonb_typeof(categories) = 'array')
+        );
+        CREATE INDEX idx_governance_audit_seals_created
+            ON public.governance_audit_seals(created_at DESC, uid);
+        CREATE INDEX idx_governance_audit_seals_period
+            ON public.governance_audit_seals(period_start, period_end);
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "governance audit evidence is append-only; "
+        "schema downgrade requires an approved archival migration"
+    )

+ 487 - 0
tests/integration/test_governance_audit_postgres.py

@@ -0,0 +1,487 @@
+from __future__ import annotations
+
+import os
+import uuid
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from sqlalchemy import text
+
+pytestmark = pytest.mark.integration
+
+
+def test_governance_audit_covers_six_sources_and_detects_tampering(monkeypatch):
+    database_url = os.environ.get("TEST_DATABASE_URL")
+    if not database_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+
+    monkeypatch.setenv("DATABASE_URL", database_url)
+    from app import create_app, db
+    from app.core.system.governance_audit import (
+        AUDIT_CATEGORIES,
+        GovernanceAuditService,
+    )
+    from app.core.system.governance_audit_repository import (
+        SqlAlchemyGovernanceAuditRepository,
+    )
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    suffix = uuid.uuid4().hex[:10]
+    ids = {name: str(uuid.uuid4()) for name in (
+        "user",
+        "source",
+        "job",
+        "asset_left",
+        "asset_right",
+        "candidate",
+        "review",
+        "merge",
+        "rollback",
+        "ontology",
+        "ontology_version",
+        "ontology_run",
+        "quality_profile",
+        "quality_version",
+        "quality_run",
+        "issue",
+        "timeline",
+        "query",
+        "correlation",
+    )}
+    now = datetime.now(UTC).replace(microsecond=0)
+    start = now - timedelta(minutes=1)
+    end = now + timedelta(minutes=1)
+
+    try:
+        with app.app_context():
+            statements = [
+                (
+                    """
+                    INSERT INTO public.users (
+                        id, username, display_name, password_hash, status
+                    ) VALUES (
+                        CAST(:user AS uuid), :username, :username,
+                        'wp12-integration-hash', 'active'
+                    )
+                    """,
+                    {"user": ids["user"], "username": f"wp12-{suffix}"},
+                ),
+                (
+                    """
+                    INSERT INTO public.auth_audit_events (
+                        user_id, username, event_type, success, detail,
+                        created_at
+                    ) VALUES (
+                        CAST(:user AS uuid), :username, 'login', TRUE,
+                        'safe integration fixture', :now
+                    )
+                    """,
+                    {
+                        "user": ids["user"],
+                        "username": f"wp12-{suffix}",
+                        "now": now,
+                    },
+                ),
+                (
+                    """
+                    INSERT INTO public.ingestion_sources (
+                        uid, source_type, name, config, permission_scope,
+                        status, created_by
+                    ) VALUES (
+                        CAST(:source AS uuid), 'database', :name,
+                        '{"password":"source-secret"}'::jsonb,
+                        '{}'::jsonb, 'active', :user
+                    )
+                    """,
+                    {
+                        "source": ids["source"],
+                        "name": f"WP12 source {suffix}",
+                        "user": ids["user"],
+                    },
+                ),
+                (
+                    """
+                    INSERT INTO public.ingestion_jobs (
+                        uid, source_uid, job_type, status, idempotency_key,
+                        parser_version, parameters, statistics, actor_uid,
+                        attempt_count, created_at
+                    ) VALUES (
+                        CAST(:job AS uuid), CAST(:source AS uuid),
+                        'database_catalog', 'published', :key, 'wp12-v1',
+                        '{}'::jsonb, '{}'::jsonb, :user, 1, :now
+                    )
+                    """,
+                    {
+                        "job": ids["job"],
+                        "source": ids["source"],
+                        "key": suffix.ljust(64, "0"),
+                        "user": ids["user"],
+                        "now": now,
+                    },
+                ),
+            ]
+            for asset_key, marker in (
+                ("asset_left", "a"),
+                ("asset_right", "b"),
+            ):
+                statements.append(
+                    (
+                        """
+                        INSERT INTO public.device_assets (
+                            uid, asset_type, name, status, current_version,
+                            content_hash, attributes, created_by, updated_by
+                        ) VALUES (
+                            CAST(:uid AS uuid), 'device', :name, 'active', 1,
+                            :hash, '{}'::jsonb, :user, :user
+                        )
+                        """,
+                        {
+                            "uid": ids[asset_key],
+                            "name": f"WP12 asset {marker} {suffix}",
+                            "hash": marker * 64,
+                            "user": ids["user"],
+                        },
+                    )
+                )
+            statements.extend(
+                [
+                    (
+                        """
+                        INSERT INTO public.device_entity_match_candidates (
+                            uid, left_asset_uid, right_asset_uid,
+                            canonical_asset_uid, status, suggestion_source,
+                            confidence, explanation, evidence_uids,
+                            current_version, created_by
+                        ) VALUES (
+                            CAST(:candidate AS uuid),
+                            CAST(:left_asset AS uuid),
+                            CAST(:right_asset AS uuid),
+                            CAST(:left_asset AS uuid), 'rolled_back',
+                            'manual', 1, '[]'::jsonb, '[]'::jsonb, 2, :user
+                        )
+                        """,
+                        {
+                            "candidate": ids["candidate"],
+                            "left_asset": ids["asset_left"],
+                            "right_asset": ids["asset_right"],
+                            "user": ids["user"],
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.device_entity_match_reviews (
+                            uid, candidate_uid, version, decision, reason,
+                            actor_uid, created_at
+                        ) VALUES (
+                            CAST(:review AS uuid), CAST(:candidate AS uuid),
+                            1, 'approve', 'safe integration fixture',
+                            :user, :now
+                        )
+                        """,
+                        {
+                            "review": ids["review"],
+                            "candidate": ids["candidate"],
+                            "user": ids["user"],
+                            "now": now,
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.device_entity_merge_events (
+                            uid, candidate_uid, canonical_asset_uid,
+                            member_asset_uid, review_uid, snapshot,
+                            actor_uid, created_at
+                        ) VALUES (
+                            CAST(:merge AS uuid), CAST(:candidate AS uuid),
+                            CAST(:left_asset AS uuid),
+                            CAST(:right_asset AS uuid),
+                            CAST(:review AS uuid),
+                            '{"password":"merge-secret"}'::jsonb,
+                            :user, :now
+                        )
+                        """,
+                        {
+                            "merge": ids["merge"],
+                            "candidate": ids["candidate"],
+                            "left_asset": ids["asset_left"],
+                            "right_asset": ids["asset_right"],
+                            "review": ids["review"],
+                            "user": ids["user"],
+                            "now": now,
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.device_entity_merge_rollbacks (
+                            uid, merge_uid, candidate_uid, reason, snapshot,
+                            actor_uid, created_at
+                        ) VALUES (
+                            CAST(:rollback AS uuid), CAST(:merge AS uuid),
+                            CAST(:candidate AS uuid), 'safe rollback',
+                            '{"password":"rollback-secret"}'::jsonb,
+                            :user, :now
+                        )
+                        """,
+                        {
+                            "rollback": ids["rollback"],
+                            "merge": ids["merge"],
+                            "candidate": ids["candidate"],
+                            "user": ids["user"],
+                            "now": now,
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.ontologies (
+                            uid, code, name, owner_uid, status,
+                            draft_revision, created_by
+                        ) VALUES (
+                            CAST(:ontology AS uuid), :code, :name, :user,
+                            'published', 1, :user
+                        )
+                        """,
+                        {
+                            "ontology": ids["ontology"],
+                            "code": f"WP12-{suffix}",
+                            "name": f"WP12 ontology {suffix}",
+                            "user": ids["user"],
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.ontology_versions (
+                            uid, ontology_uid, version, status,
+                            graph_document, content_hash, created_by,
+                            created_at, published_at
+                        ) VALUES (
+                            CAST(:version AS uuid), CAST(:ontology AS uuid),
+                            1, 'published', '{}'::jsonb, :hash, :user,
+                            :now, :now
+                        )
+                        """,
+                        {
+                            "version": ids["ontology_version"],
+                            "ontology": ids["ontology"],
+                            "hash": "c" * 64,
+                            "user": ids["user"],
+                            "now": now,
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.ontology_publish_runs (
+                            uid, ontology_uid, version_uid, idempotency_key,
+                            status, actor_uid, created_at, finished_at
+                        ) VALUES (
+                            CAST(:run AS uuid), CAST(:ontology AS uuid),
+                            CAST(:version AS uuid), :key, 'published',
+                            :user, :now, :now
+                        )
+                        """,
+                        {
+                            "run": ids["ontology_run"],
+                            "ontology": ids["ontology"],
+                            "version": ids["ontology_version"],
+                            "key": f"wp12-{suffix}",
+                            "user": ids["user"],
+                            "now": now,
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.device_quality_profiles (
+                            uid, code, name, created_by
+                        ) VALUES (
+                            CAST(:profile AS uuid), :code, :name, :user
+                        )
+                        """,
+                        {
+                            "profile": ids["quality_profile"],
+                            "code": f"wp12-quality-{suffix}",
+                            "name": f"WP12 quality {suffix}",
+                            "user": ids["user"],
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.device_quality_profile_versions (
+                            uid, profile_uid, version, status, rules,
+                            content_hash, created_by, published_by,
+                            created_at, published_at
+                        ) VALUES (
+                            CAST(:version AS uuid), CAST(:profile AS uuid),
+                            1, 'published', '[]'::jsonb, :hash,
+                            :user, :user, :now, :now
+                        )
+                        """,
+                        {
+                            "version": ids["quality_version"],
+                            "profile": ids["quality_profile"],
+                            "hash": "d" * 64,
+                            "user": ids["user"],
+                            "now": now,
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.device_quality_runs (
+                            uid, policy_version_uid, policy_hash, source_uid,
+                            status, total_assets, total_violations, score,
+                            created_by, created_at
+                        ) VALUES (
+                            CAST(:run AS uuid), CAST(:version AS uuid), :hash,
+                            CAST(:source AS uuid), 'success', 1, 1, 0,
+                            :user, :now
+                        )
+                        """,
+                        {
+                            "run": ids["quality_run"],
+                            "version": ids["quality_version"],
+                            "hash": "d" * 64,
+                            "source": ids["source"],
+                            "user": ids["user"],
+                            "now": now,
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.device_quality_issues (
+                            uid, issue_code, source_violation_uid,
+                            source_run_uid, rule_code, severity, priority,
+                            asset_uid, field_name, message, evidence,
+                            recurrence_key, occurrence_number, status,
+                            current_version, created_by, updated_by
+                        ) VALUES (
+                            CAST(:issue AS uuid), :code,
+                            CAST(:violation AS uuid), CAST(:run AS uuid),
+                            'WP12-RULE', 'error', 'high',
+                            CAST(:asset AS uuid), 'name', 'safe message',
+                            '{"password":"issue-secret"}'::jsonb, :key,
+                            1, 'closed', 1,
+                            CAST(:user AS uuid), CAST(:user AS uuid)
+                        )
+                        """,
+                        {
+                            "issue": ids["issue"],
+                            "code": f"WP12-{suffix}",
+                            "violation": str(uuid.uuid4()),
+                            "run": ids["quality_run"],
+                            "asset": ids["asset_left"],
+                            "key": "e" * 64,
+                            "user": ids["user"],
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.device_quality_issue_timeline (
+                            uid, issue_uid, action, from_status, to_status,
+                            actor_uid, note, payload, created_at
+                        ) VALUES (
+                            CAST(:timeline AS uuid), CAST(:issue AS uuid),
+                            'closed', 'pending_review', 'closed',
+                            CAST(:user AS uuid), 'must not be selected',
+                            '{"password":"timeline-secret"}'::jsonb, :now
+                        )
+                        """,
+                        {
+                            "timeline": ids["timeline"],
+                            "issue": ids["issue"],
+                            "user": ids["user"],
+                            "now": now,
+                        },
+                    ),
+                    (
+                        """
+                        INSERT INTO public.knowledge_query_audits (
+                            id, query_hash, user_id, roles,
+                            business_domain_uids, mode, retriever_counts,
+                            cited_points, degraded_components, correlation_id,
+                            latency_ms, created_at
+                        ) VALUES (
+                            CAST(:query AS uuid), :hash, CAST(:user AS uuid),
+                            '["admin"]'::jsonb, '[]'::jsonb, 'hybrid',
+                            CAST(:retriever_counts AS jsonb),
+                            '["point-1"]'::jsonb,
+                            '[]'::jsonb, CAST(:correlation AS uuid), 12, :now
+                        )
+                        """,
+                        {
+                            "query": ids["query"],
+                            "hash": "f" * 64,
+                            "user": ids["user"],
+                            "correlation": ids["correlation"],
+                            "retriever_counts": '{"canonical":1}',
+                            "now": now,
+                        },
+                    ),
+                ]
+            )
+            for statement, params in statements:
+                db.session.execute(text(statement), params)
+            db.session.flush()
+
+            service = GovernanceAuditService(
+                SqlAlchemyGovernanceAuditRepository(db.session),
+                evidence_secret="wp12-integration-secret-with-32-bytes",
+                key_version="integration-v1",
+                now_factory=lambda: end,
+            )
+            coverage = service.coverage(
+                period_start=start,
+                period_end=end,
+            )
+            assert [item["category"] for item in coverage["categories"]] == list(
+                AUDIT_CATEGORIES
+            )
+            assert all(item["count"] >= 1 for item in coverage["categories"])
+
+            events = service.list_events(
+                period_start=start,
+                period_end=end,
+                page_size=100,
+            )
+            serialized = repr(events)
+            for secret in (
+                "source-secret",
+                "merge-secret",
+                "rollback-secret",
+                "issue-secret",
+                "timeline-secret",
+                "must not be selected",
+            ):
+                assert secret not in serialized
+
+            seal = service.create_seal(
+                period_start=start,
+                period_end=end,
+                actor_uid=ids["user"],
+            )
+            assert service.verify_seal(seal["uid"])["integrity_status"] == (
+                "intact"
+            )
+
+            nested = db.session.begin_nested()
+            db.session.execute(
+                text(
+                    """
+                    UPDATE public.auth_audit_events
+                    SET success = FALSE
+                    WHERE username = :username
+                      AND created_at = :now
+                    """
+                ),
+                {"username": f"wp12-{suffix}", "now": now},
+            )
+            db.session.flush()
+            assert service.verify_seal(seal["uid"])["integrity_status"] == (
+                "tampered"
+            )
+            nested.rollback()
+            assert service.verify_seal(seal["uid"])["integrity_status"] == (
+                "intact"
+            )
+    finally:
+        with app.app_context():
+            db.session.rollback()
+            db.session.remove()

+ 274 - 0
tests/system/test_governance_audit.py

@@ -0,0 +1,274 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+
+class FakeAuditRepository:
+    def __init__(self, events=None):
+        self.events = list(events or [])
+        self.seals = {}
+
+    def fetch_events(self, *, categories, period_start, period_end):
+        return [
+            deepcopy(event)
+            for event in self.events
+            if event["category"] in categories
+            and period_start <= event["occurred_at"] <= period_end
+        ]
+
+    def save_seal(self, seal):
+        self.seals[seal["uid"]] = deepcopy(seal)
+        return deepcopy(seal)
+
+    def get_seal(self, seal_uid):
+        seal = self.seals.get(seal_uid)
+        return deepcopy(seal) if seal else None
+
+    def list_seals(self, *, limit):
+        return list(self.seals.values())[:limit]
+
+
+def _event(
+    uid,
+    category,
+    *,
+    occurred_at,
+    action="completed",
+    status="success",
+    detail=None,
+):
+    return {
+        "event_uid": uid,
+        "category": category,
+        "action": action,
+        "status": status,
+        "actor_uid": "actor-1",
+        "resource_type": "device_asset",
+        "resource_uid": "asset-1",
+        "occurred_at": occurred_at,
+        "safe_detail": detail or {"attempt": 1},
+    }
+
+
+def _service(events):
+    from app.core.system.governance_audit import GovernanceAuditService
+
+    return GovernanceAuditService(
+        FakeAuditRepository(events),
+        evidence_secret="wp12-test-secret-with-at-least-32-bytes",
+        key_version="test-v1",
+        uid_factory=lambda: "seal-1",
+        now_factory=lambda: datetime(2026, 7, 31, tzinfo=UTC),
+    )
+
+
+def test_list_events_is_deterministic_bounded_and_secret_safe():
+    from app.core.system.governance_audit import GovernanceAuditInvalid
+
+    now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
+    events = [
+        _event(
+            "event-b",
+            "ingestion",
+            occurred_at=now,
+            detail={
+                "attempt": 2,
+                "password": "must-not-leak",
+                "nested": {"authorization": "Bearer secret"},
+            },
+        ),
+        _event("event-a", "authentication", occurred_at=now),
+    ]
+    service = _service(events)
+
+    result = service.list_events(
+        period_start=now - timedelta(days=1),
+        period_end=now + timedelta(days=1),
+        page=1,
+        page_size=1,
+    )
+
+    assert result["total"] == 2
+    assert result["records"][0]["event_uid"] == "event-b"
+    assert result["records"][0]["safe_detail"] == {
+        "attempt": 2,
+        "password": "[redacted]",
+        "nested": {"authorization": "[redacted]"},
+    }
+    assert "must-not-leak" not in repr(result)
+    assert "Bearer secret" not in repr(result)
+
+    with pytest.raises(GovernanceAuditInvalid, match="page_size"):
+        service.list_events(
+            period_start=now - timedelta(days=1),
+            period_end=now,
+            page_size=101,
+        )
+
+    with pytest.raises(GovernanceAuditInvalid, match="category"):
+        service.list_events(
+            period_start=now - timedelta(days=1),
+            period_end=now,
+            categories=["arbitrary"],
+        )
+
+
+def test_coverage_always_returns_the_six_fixed_categories():
+    from app.core.system.governance_audit import AUDIT_CATEGORIES
+
+    now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
+    service = _service(
+        [_event("event-1", "authentication", occurred_at=now)]
+    )
+
+    coverage = service.coverage(
+        period_start=now - timedelta(days=1),
+        period_end=now + timedelta(seconds=1),
+    )
+
+    assert [item["category"] for item in coverage["categories"]] == list(
+        AUDIT_CATEGORIES
+    )
+    assert coverage["categories"][0]["count"] == 1
+    assert all(
+        item["count"] == 0 for item in coverage["categories"][1:]
+    )
+
+
+def test_signed_seal_is_stable_and_detects_changed_or_missing_events():
+    now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
+    events = [
+        _event("event-1", "authentication", occurred_at=now),
+        _event("event-2", "ingestion", occurred_at=now + timedelta(seconds=1)),
+    ]
+    repository = FakeAuditRepository(events)
+    from app.core.system.governance_audit import GovernanceAuditService
+
+    service = GovernanceAuditService(
+        repository,
+        evidence_secret="wp12-test-secret-with-at-least-32-bytes",
+        key_version="test-v1",
+        uid_factory=lambda: "seal-1",
+        now_factory=lambda: now + timedelta(days=1),
+    )
+
+    seal = service.create_seal(
+        period_start=now - timedelta(seconds=1),
+        period_end=now + timedelta(seconds=2),
+        categories=["authentication", "ingestion"],
+        actor_uid="admin-1",
+    )
+
+    assert seal["event_count"] == 2
+    assert len(seal["root_hash"]) == 64
+    assert len(seal["signature"]) == 64
+    assert service.verify_seal("seal-1")["integrity_status"] == "intact"
+
+    repository.events[1]["status"] = "failed"
+    changed = service.verify_seal("seal-1")
+    assert changed["integrity_status"] == "tampered"
+    assert changed["actual_event_count"] == 2
+    assert changed["actual_root_hash"] != seal["root_hash"]
+
+    repository.events.pop()
+    missing = service.verify_seal("seal-1")
+    assert missing["integrity_status"] == "tampered"
+    assert missing["actual_event_count"] == 1
+
+
+def test_seal_signature_rejects_modified_metadata_and_weak_secret():
+    from app.core.system.governance_audit import (
+        GovernanceAuditInvalid,
+        GovernanceAuditService,
+    )
+
+    now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
+    repository = FakeAuditRepository(
+        [_event("event-1", "authentication", occurred_at=now)]
+    )
+    service = GovernanceAuditService(
+        repository,
+        evidence_secret="wp12-test-secret-with-at-least-32-bytes",
+        key_version="test-v1",
+        uid_factory=lambda: "seal-1",
+        now_factory=lambda: now + timedelta(days=1),
+    )
+    service.create_seal(
+        period_start=now - timedelta(seconds=1),
+        period_end=now + timedelta(seconds=1),
+        actor_uid="admin-1",
+    )
+    repository.seals["seal-1"]["event_count"] = 99
+
+    assert (
+        service.verify_seal("seal-1")["integrity_status"]
+        == "invalid_signature"
+    )
+
+    with pytest.raises(GovernanceAuditInvalid, match="32"):
+        GovernanceAuditService(
+            repository,
+            evidence_secret="short",
+            key_version="test-v1",
+        )
+
+
+def test_seal_rejects_invalid_windows_and_unbounded_event_sets():
+    from app.core.system.governance_audit import (
+        GovernanceAuditInvalid,
+        GovernanceAuditService,
+    )
+
+    now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
+    service = _service([])
+    with pytest.raises(GovernanceAuditInvalid, match="period_end"):
+        service.create_seal(
+            period_start=now,
+            period_end=now,
+            actor_uid="admin-1",
+        )
+
+    class OversizedRepository(FakeAuditRepository):
+        def fetch_events(self, **_kwargs):
+            event = _event(
+                "event-1", "authentication", occurred_at=now
+            )
+            return [event] * 50_001
+
+    oversized = GovernanceAuditService(
+        OversizedRepository(),
+        evidence_secret="wp12-test-secret-with-at-least-32-bytes",
+        key_version="test-v1",
+        now_factory=lambda: now + timedelta(days=1),
+    )
+    with pytest.raises(GovernanceAuditInvalid, match="50,000"):
+        oversized.create_seal(
+            period_start=now - timedelta(seconds=1),
+            period_end=now + timedelta(seconds=1),
+            actor_uid="admin-1",
+        )
+
+
+def test_seal_rejects_a_window_that_ends_in_the_future():
+    from app.core.system.governance_audit import (
+        GovernanceAuditInvalid,
+        GovernanceAuditService,
+    )
+
+    now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
+    service = GovernanceAuditService(
+        FakeAuditRepository(),
+        evidence_secret="wp12-test-secret-with-at-least-32-bytes",
+        key_version="test-v1",
+        now_factory=lambda: now,
+    )
+
+    with pytest.raises(GovernanceAuditInvalid, match="future"):
+        service.create_seal(
+            period_start=now - timedelta(days=1),
+            period_end=now + timedelta(seconds=1),
+            actor_uid="admin-1",
+        )

+ 242 - 0
tests/system/test_governance_audit_api.py

@@ -0,0 +1,242 @@
+from __future__ import annotations
+
+
+class FakeGovernanceAuditService:
+    def __init__(self):
+        self.calls = []
+
+    def coverage(self, **filters):
+        self.calls.append(("coverage", filters))
+        return {
+            "period_start": filters["period_start"].isoformat(),
+            "period_end": filters["period_end"].isoformat(),
+            "categories": [
+                {
+                    "category": "authentication",
+                    "count": 1,
+                    "available": True,
+                    "latest_at": "2026-07-30T00:00:00Z",
+                }
+            ],
+        }
+
+    def list_events(self, **filters):
+        self.calls.append(("events", filters))
+        return {
+            "records": [
+                {
+                    "event_uid": "authentication:1",
+                    "category": "authentication",
+                    "action": "login",
+                    "status": "success",
+                    "safe_detail": {"username": "admin"},
+                }
+            ],
+            "page": int(filters["page"]),
+            "page_size": int(filters["page_size"]),
+            "total": 1,
+        }
+
+    def create_seal(self, **payload):
+        self.calls.append(("create_seal", payload))
+        return {
+            "uid": "seal-1",
+            "event_count": 1,
+            "root_hash": "a" * 64,
+            "signature": "b" * 64,
+        }
+
+    def list_seals(self, *, limit):
+        self.calls.append(("list_seals", {"limit": limit}))
+        return [{"uid": "seal-1", "event_count": 1}]
+
+    def verify_seal(self, seal_uid):
+        self.calls.append(("verify_seal", {"seal_uid": seal_uid}))
+        return {"uid": seal_uid, "integrity_status": "intact"}
+
+
+def _client(monkeypatch, *, role="admin"):
+    from app import create_app
+    from app.api.system import governance_audit
+
+    service = FakeGovernanceAuditService()
+    identity = {
+        "id": "00000000-0000-7000-8000-000000000111",
+        "roles": [role],
+    }
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: identity,
+    )
+    monkeypatch.setattr(
+        governance_audit,
+        "get_governance_audit_service",
+        lambda: service,
+    )
+    monkeypatch.setattr(
+        governance_audit,
+        "get_security_checks",
+        lambda: {
+            "seal_ready": True,
+            "assurance": "签名封存用于检测篡改,不等于阻止数据库管理员修改",
+            "checks": [{"code": "safe_error_boundary", "status": "passed"}],
+        },
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    return app.test_client(), service
+
+
+def test_admin_reads_security_coverage_events_and_seals(monkeypatch):
+    http, service = _client(monkeypatch)
+    window = (
+        "period_start=2026-07-01T00:00:00Z&"
+        "period_end=2026-07-30T00:00:00Z"
+    )
+
+    security = http.get(
+        "/api/system/governance-audit/security-checks"
+    )
+    coverage = http.get(
+        f"/api/system/governance-audit/coverage?{window}"
+    )
+    events = http.get(
+        f"/api/system/governance-audit/events?{window}"
+        "&category=authentication&page=1&page_size=20"
+    )
+    seals = http.get("/api/system/governance-audit/seals?limit=25")
+
+    assert security.status_code == 200
+    assert security.get_json()["data"]["seal_ready"] is True
+    assert coverage.status_code == 200
+    assert events.status_code == 200
+    assert events.get_json()["data"]["records"][0]["safe_detail"] == {
+        "username": "admin"
+    }
+    assert seals.status_code == 200
+    assert [call[0] for call in service.calls] == [
+        "coverage",
+        "events",
+        "list_seals",
+    ]
+
+
+def test_seal_creation_uses_server_identity_and_verification_is_explicit(
+    monkeypatch,
+):
+    http, service = _client(monkeypatch)
+
+    created = http.post(
+        "/api/system/governance-audit/seals",
+        json={
+            "period_start": "2026-07-01T00:00:00Z",
+            "period_end": "2026-07-30T00:00:00Z",
+            "categories": ["authentication", "ingestion"],
+            "sealed_by": "attacker-controlled",
+        },
+    )
+    verified = http.post(
+        "/api/system/governance-audit/seals/seal-1/verify"
+    )
+
+    assert created.status_code == 200
+    assert service.calls[0][1]["actor_uid"] == (
+        "00000000-0000-7000-8000-000000000111"
+    )
+    assert "sealed_by" not in service.calls[0][1]
+    assert verified.status_code == 200
+    assert verified.get_json()["data"]["integrity_status"] == "intact"
+
+
+def test_viewer_and_editor_cannot_read_or_seal(monkeypatch):
+    for role in ("viewer", "editor"):
+        http, _service = _client(monkeypatch, role=role)
+        assert http.get(
+            "/api/system/governance-audit/events"
+        ).status_code == 403
+        assert http.post(
+            "/api/system/governance-audit/seals",
+            json={},
+        ).status_code == 403
+
+
+def test_invalid_query_and_repository_failure_return_safe_contract(monkeypatch):
+    from app.core.system.governance_audit import GovernanceAuditInvalid
+
+    http, service = _client(monkeypatch)
+
+    def invalid(**_kwargs):
+        raise GovernanceAuditInvalid("page_size must be between 1 and 100")
+
+    service.list_events = invalid
+    response = http.get(
+        "/api/system/governance-audit/events"
+        "?period_start=2026-07-01T00:00:00Z"
+        "&period_end=2026-07-30T00:00:00Z&page_size=101"
+    )
+    assert response.status_code == 400
+    assert response.get_json()["error"]["code"] == (
+        "GOVERNANCE_AUDIT_INVALID"
+    )
+
+    def unavailable(**_kwargs):
+        raise RuntimeError(
+            "postgresql://admin:database-secret@db/dataops "
+            "authorization=Bearer-secret"
+        )
+
+    service.list_events = unavailable
+    failed = http.get(
+        "/api/system/governance-audit/events"
+        "?period_start=2026-07-01T00:00:00Z"
+        "&period_end=2026-07-30T00:00:00Z"
+    )
+    payload = failed.get_json()
+    assert failed.status_code == 503
+    assert payload["message"] == "审计与运行证据暂不可用"
+    assert "database-secret" not in repr(payload)
+    assert "Bearer-secret" not in repr(payload)
+
+
+def test_production_without_dedicated_key_blocks_seal_and_verify(monkeypatch):
+    from flask import Flask
+
+    from app.api.system import governance_audit
+
+    production = Flask(__name__)
+    production.config.update(
+        FLASK_ENV="production",
+        TESTING=False,
+    )
+    development = Flask(__name__)
+    development.config.update(
+        FLASK_ENV="development",
+        TESTING=False,
+    )
+
+    with production.app_context():
+        assert governance_audit._sealing_allowed(False) is False
+        assert governance_audit._sealing_allowed(True) is True
+    with development.app_context():
+        assert governance_audit._sealing_allowed(False) is True
+
+    http, service = _client(monkeypatch)
+    monkeypatch.setattr(
+        governance_audit,
+        "_sealing_allowed",
+        lambda _dedicated_ready: False,
+    )
+    created = http.post(
+        "/api/system/governance-audit/seals",
+        json={
+            "period_start": "2026-07-01T00:00:00Z",
+            "period_end": "2026-07-30T00:00:00Z",
+        },
+    )
+    verified = http.post(
+        "/api/system/governance-audit/seals/seal-1/verify"
+    )
+
+    assert created.status_code == 503
+    assert verified.status_code == 503
+    assert service.calls == []

+ 60 - 0
tests/system/test_governance_audit_frontend_contract.py

@@ -0,0 +1,60 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_audit_workbench_exposes_security_coverage_events_and_seals():
+    view = (
+        ROOT / "frontend/src/views/systemManage/governanceAudit/index.vue"
+    ).read_text(encoding="utf-8")
+    api = (
+        ROOT / "frontend/src/api/governanceAudit.js"
+    ).read_text(encoding="utf-8")
+    routes = (
+        ROOT / "frontend/src/router/routes.js"
+    ).read_text(encoding="utf-8")
+
+    for text in (
+        "审计与运行证据",
+        "安全检查",
+        "关键操作覆盖",
+        "审计事件",
+        "证据封存",
+        "独立封存密钥已配置",
+        "签名封存用于检测篡改,不等于阻止数据库管理员修改",
+    ):
+        assert text in view
+    for path in (
+        "/system/governance-audit/security-checks",
+        "/system/governance-audit/coverage",
+        "/system/governance-audit/events",
+        "/system/governance-audit/seals",
+        "/verify",
+    ):
+        assert path in api
+    assert "systemManage/governanceAudit" in routes
+    assert "governance-audit:read" in routes
+    assert "governance-audit:seal" in routes
+
+
+def test_audit_workbench_never_requests_or_renders_unsafe_columns():
+    view = (
+        ROOT / "frontend/src/views/systemManage/governanceAudit/index.vue"
+    ).read_text(encoding="utf-8")
+    model = (
+        ROOT
+        / "frontend/src/views/systemManage/governanceAudit/"
+        "governanceAuditModel.js"
+    ).read_text(encoding="utf-8")
+    combined = view + model
+
+    for forbidden in (
+        "ip_address",
+        "user_agent",
+        "raw_question",
+        "remediation_note",
+        "evidence_payload",
+        "source_config",
+        "encrypted_payload",
+    ):
+        assert forbidden not in combined

+ 187 - 0
tests/system/test_governance_audit_repository.py

@@ -0,0 +1,187 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+
+class FakeRows:
+    def __init__(self, rows=None):
+        self.rows = list(rows or [])
+
+    def mappings(self):
+        return self
+
+    def all(self):
+        return self.rows
+
+    def one(self):
+        return self.rows[0]
+
+    def one_or_none(self):
+        return self.rows[0] if self.rows else None
+
+
+class RecordingSession:
+    def __init__(self, responses=None):
+        self.responses = list(responses or [])
+        self.calls = []
+
+    def execute(self, statement, params=None):
+        self.calls.append((str(statement), dict(params or {})))
+        rows = self.responses.pop(0) if self.responses else []
+        return FakeRows(rows)
+
+    def flush(self):
+        return None
+
+
+def test_repository_queries_six_authoritative_sources_with_safe_projections():
+    from app.core.system.governance_audit import AUDIT_CATEGORIES
+    from app.core.system.governance_audit_repository import (
+        SqlAlchemyGovernanceAuditRepository,
+    )
+
+    session = RecordingSession()
+    repository = SqlAlchemyGovernanceAuditRepository(session)
+    now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
+
+    assert repository.fetch_events(
+        categories=AUDIT_CATEGORIES,
+        period_start=now - timedelta(days=1),
+        period_end=now,
+    ) == []
+
+    sql = "\n".join(statement for statement, _ in session.calls).lower()
+    for table in (
+        "auth_audit_events",
+        "ingestion_jobs",
+        "device_entity_match_reviews",
+        "device_entity_merge_rollbacks",
+        "ontology_publish_runs",
+        "device_semantic_code_reviews",
+        "device_quality_profile_versions",
+        "device_quality_issue_timeline",
+        "knowledge_query_audits",
+    ):
+        assert table in sql
+    for forbidden in (
+        "select ip_address",
+        "select user_agent",
+        "last_error",
+        "source.config",
+        "encrypted_payload",
+        "snapshot",
+        "timeline.note",
+        "timeline.payload",
+        "query text",
+    ):
+        assert forbidden not in sql
+    assert all(
+        params["period_start"] == now - timedelta(days=1)
+        and params["period_end"] == now
+        for _, params in session.calls
+    )
+
+
+def test_repository_normalizes_rows_without_exposing_raw_payloads():
+    from app.core.system.governance_audit_repository import (
+        SqlAlchemyGovernanceAuditRepository,
+    )
+
+    now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
+    session = RecordingSession(
+        [
+            [
+                {
+                    "event_uid": "authentication:7",
+                    "category": "authentication",
+                    "action": "login",
+                    "status": "success",
+                    "actor_uid": "admin",
+                    "resource_type": "user",
+                    "resource_uid": "user-1",
+                    "occurred_at": now,
+                    "safe_detail": {"username": "admin"},
+                }
+            ]
+        ]
+    )
+    repository = SqlAlchemyGovernanceAuditRepository(session)
+
+    events = repository.fetch_events(
+        categories=["authentication"],
+        period_start=now - timedelta(days=1),
+        period_end=now + timedelta(days=1),
+    )
+
+    assert events == [
+        {
+            "event_uid": "authentication:7",
+            "category": "authentication",
+            "action": "login",
+            "status": "success",
+            "actor_uid": "admin",
+            "resource_type": "user",
+            "resource_uid": "user-1",
+            "occurred_at": now,
+            "safe_detail": {"username": "admin"},
+        }
+    ]
+
+
+def test_security_snapshot_checks_nested_secret_keys():
+    from app.core.system.governance_audit_repository import (
+        SqlAlchemyGovernanceAuditRepository,
+    )
+
+    session = RecordingSession(
+        [
+            [{"credential_count": 0, "encrypted_credential_count": 0}],
+            [{"plaintext_source_config_count": 0}],
+        ]
+    )
+
+    SqlAlchemyGovernanceAuditRepository(session).security_snapshot()
+
+    source_sql = session.calls[1][0].lower()
+    assert '$.** ? (@.type() == "object").keyvalue()' in source_sql
+    assert "like_regex" in source_sql
+    assert "password" in source_sql
+    assert "authorization" in source_sql
+
+
+def test_repository_persists_and_reads_append_only_seals():
+    from app.core.system.governance_audit_repository import (
+        SqlAlchemyGovernanceAuditRepository,
+    )
+
+    now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
+    stored = {
+        "uid": "seal-1",
+        "period_start": now,
+        "period_end": now + timedelta(hours=1),
+        "categories": ["authentication"],
+        "event_count": 1,
+        "root_hash": "a" * 64,
+        "signature": "b" * 64,
+        "key_version": "v1",
+        "sealed_by": "admin-1",
+        "created_at": now,
+    }
+    session = RecordingSession([[stored], [stored], [stored]])
+    repository = SqlAlchemyGovernanceAuditRepository(session)
+
+    saved = repository.save_seal(
+        {
+            **stored,
+            "period_start": stored["period_start"].isoformat(),
+            "period_end": stored["period_end"].isoformat(),
+        }
+    )
+    loaded = repository.get_seal("seal-1")
+    listed = repository.list_seals(limit=10)
+
+    assert saved["uid"] == loaded["uid"] == listed[0]["uid"]
+    sql = "\n".join(statement for statement, _ in session.calls).upper()
+    assert "INSERT INTO PUBLIC.GOVERNANCE_AUDIT_SEALS" in sql
+    assert "UPDATE PUBLIC.GOVERNANCE_AUDIT_SEALS" not in sql
+    assert "DELETE FROM PUBLIC.GOVERNANCE_AUDIT_SEALS" not in sql

+ 67 - 0
tests/system/test_safe_error_boundary.py

@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+import logging
+
+
+def test_global_error_boundary_redacts_secrets_and_returns_correlation_id(
+    caplog,
+    monkeypatch,
+):
+    from flask import Blueprint
+
+    from app import create_app
+
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: {
+            "id": "00000000-0000-7000-8000-000000000111",
+            "roles": ["admin"],
+        },
+    )
+    app = create_app()
+    app.config.update(TESTING=False, PROPAGATE_EXCEPTIONS=False)
+    failure = Blueprint("wp12_failure", __name__)
+
+    @failure.get("/api/wp12/failure")
+    def unsafe_failure():
+        raise RuntimeError(
+            "password=database-secret "
+            "postgresql://admin:url-secret@db/dataops "
+            "Authorization: Bearer bearer-secret"
+        )
+
+    app.register_blueprint(failure)
+    with caplog.at_level(logging.ERROR):
+        response = app.test_client().get("/api/wp12/failure")
+
+    assert response.status_code == 500
+    payload = response.get_json()
+    assert payload["message"] == "服务器内部错误"
+    assert len(payload["correlation_id"]) == 36
+    combined = caplog.text + repr(payload)
+    for secret in ("database-secret", "url-secret", "bearer-secret"):
+        assert secret not in combined
+
+
+def test_audit_and_auth_responses_disable_storage_and_add_security_headers(
+    monkeypatch,
+):
+    from app import create_app
+
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: {
+            "id": "00000000-0000-7000-8000-000000000111",
+            "roles": ["admin"],
+        },
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    response = app.test_client().get("/api/system/auth/me")
+
+    assert response.status_code == 200
+    assert response.headers["Cache-Control"] == "no-store"
+    assert response.headers["Referrer-Policy"] == "no-referrer"
+    assert response.headers["Permissions-Policy"] == (
+        "camera=(), microphone=(), geolocation=()"
+    )

+ 45 - 0
tests/test_architecture_artifacts.py

@@ -148,6 +148,51 @@ def test_wp11_governance_metric_contract_and_boundaries_are_documented():
         assert term in data_model
 
 
+def test_wp12_security_audit_contract_and_boundaries_are_documented():
+    contract = (ARCH / "OPENAPI.yaml").read_text(encoding="utf-8")
+    data_model = (ARCH / "DATA_MODEL.md").read_text(encoding="utf-8")
+    validation = ROOT / "docs/validation/WP12_SECURITY_AUDIT_EVIDENCE.md"
+
+    for path in (
+        "/api/system/governance-audit/security-checks",
+        "/api/system/governance-audit/coverage",
+        "/api/system/governance-audit/events",
+        "/api/system/governance-audit/seals",
+        "/api/system/governance-audit/seals/{seal_uid}/verify",
+    ):
+        assert path in contract
+    for term in (
+        "governance_audit_seals",
+        "HMAC-SHA256",
+        "authentication",
+        "ingestion",
+        "entity_resolution",
+        "publication",
+        "remediation",
+        "knowledge_query",
+        "不等于阻止数据库管理员修改",
+        "50,000",
+    ):
+        assert term in data_model
+    assert validation.exists()
+
+
+def test_wp12_release_copy_matches_authoritative_backend_files():
+    for relative in (
+        "app/__init__.py",
+        "app/api/system/__init__.py",
+        "app/api/system/governance_audit.py",
+        "app/config/config.py",
+        "app/core/data_source/redaction.py",
+        "app/core/system/governance_audit.py",
+        "app/core/system/governance_audit_repository.py",
+        "app/core/system/permissions.py",
+    ):
+        source = ROOT / relative
+        release = ROOT / "deployment" / relative
+        assert release.read_bytes() == source.read_bytes(), relative
+
+
 def test_contract_ci_regenerates_and_checks_the_committed_inventory():
     workflow = (ROOT / ".github" / "workflows" / "contracts.yml").read_text(
         encoding="utf-8"

+ 20 - 0
tests/test_database_migrations.py

@@ -58,6 +58,7 @@ EXPECTED_UPGRADED_TABLES = {
     "device_semantic_codes",
     "device_semantic_code_versions",
     "device_semantic_code_reviews",
+    "governance_audit_seals",
 }
 
 
@@ -224,6 +225,25 @@ def test_device_quality_responsibility_type_migration_is_additive():
     assert "DELETE FROM" not in migration.upper()
 
 
+def test_governance_audit_seal_migration_is_append_only_and_constrained():
+    migration = (
+        ROOT
+        / "migrations"
+        / "versions"
+        / "20260730_360_governance_audit_seals.py"
+    ).read_text(encoding="utf-8")
+
+    assert 'revision = "20260730_360"' in migration
+    assert 'down_revision = "20260729_350"' in migration
+    assert "CREATE TABLE public.governance_audit_seals" in migration
+    assert "root_hash CHAR(64) NOT NULL" in migration
+    assert "signature CHAR(64) NOT NULL" in migration
+    assert "event_count INTEGER NOT NULL" in migration
+    assert "raise RuntimeError" in migration
+    assert "DROP TABLE" not in migration.upper()
+    assert "DELETE FROM" not in migration.upper()
+
+
 def test_data_element_migration_adds_versioned_governance_tables():
     migration = (
         ROOT

+ 11 - 0
tests/test_permission_matrix.py

@@ -34,6 +34,9 @@ def test_fixed_role_permission_matrix_is_monotonic():
     assert "quality-issues:review" not in editor
     assert "quality-issues:review" in admin
     assert "device-observability:edit" in editor
+    assert "governance-audit:read" not in editor
+    assert "governance-audit:read" in admin
+    assert "governance-audit:seal" in admin
 
 
 def test_data_development_paths_have_specific_write_policies():
@@ -150,6 +153,14 @@ def test_data_development_paths_have_specific_write_policies():
         "/api/development/v1/governance-metrics/details",
         "GET",
     ) == ("governance:read",)
+    assert permission_for_request(
+        "/api/system/governance-audit/events",
+        "GET",
+    ) == ("governance-audit:read",)
+    assert permission_for_request(
+        "/api/system/governance-audit/seals",
+        "POST",
+    ) == ("governance-audit:seal",)
 
 
 def test_business_domain_read_endpoints_are_available_to_viewers():