Browse Source

feat: add active metadata discovery and lineage

马小龙 3 weeks ago
parent
commit
8bf580fa5d
33 changed files with 5081 additions and 10 deletions
  1. 21 0
      app/api/data_development/routes.py
  2. 1 0
      app/api/meta_data/__init__.py
  3. 154 0
      app/api/meta_data/active_metadata.py
  4. 4 2
      app/core/data_research/catalog/execution.py
  5. 703 0
      app/core/meta_data/active_metadata.py
  6. 500 0
      app/core/meta_data/active_metadata_repository.py
  7. 15 0
      app/core/system/permissions.py
  8. 20 0
      app/models/__init__.py
  9. 160 0
      app/models/active_metadata.py
  10. 21 0
      deployment/app/api/data_development/routes.py
  11. 1 0
      deployment/app/api/meta_data/__init__.py
  12. 154 0
      deployment/app/api/meta_data/active_metadata.py
  13. 4 2
      deployment/app/core/data_research/catalog/execution.py
  14. 703 0
      deployment/app/core/meta_data/active_metadata.py
  15. 500 0
      deployment/app/core/meta_data/active_metadata_repository.py
  16. 15 0
      deployment/app/core/system/permissions.py
  17. 20 0
      deployment/app/models/__init__.py
  18. 160 0
      deployment/app/models/active_metadata.py
  19. 5 5
      docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md
  20. 24 0
      docs/architecture/DATA_MODEL.md
  21. 278 1
      docs/architecture/OPENAPI.yaml
  22. 57 0
      docs/validation/P2_WP02_ACTIVE_METADATA_EVIDENCE.md
  23. 19 0
      frontend/src/api/activeMetadata.js
  24. 12 0
      frontend/src/router/routes.js
  25. 395 0
      frontend/src/views/dataGovernance/development/activeMetadata.vue
  26. 1 0
      frontend/src/views/dataGovernance/development/index.vue
  27. 199 0
      migrations/versions/20260730_380_active_metadata_lineage.py
  28. 400 0
      tests/core/data_source/test_active_metadata.py
  29. 32 0
      tests/core/data_source/test_active_metadata_frontend_contract.py
  30. 25 0
      tests/data_research/test_catalog_execution.py
  31. 320 0
      tests/integration/test_active_metadata_postgres.py
  32. 128 0
      tests/test_active_metadata_api.py
  33. 30 0
      tests/test_phase2_wp02_migration_contract.py

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

@@ -65,6 +65,10 @@ def get_catalog_ingestion_executor():
     from app.core.data_research.catalog.service import CatalogCollectionService
     from app.core.data_research.errors import IngestionSourceInvalid
     from app.core.data_source.runtime import get_data_source_manager
+    from app.core.meta_data.active_metadata import ActiveMetadataService
+    from app.core.meta_data.active_metadata_repository import (
+        SqlAlchemyActiveMetadataRepository,
+    )
 
     manager = get_data_source_manager()
 
@@ -82,12 +86,29 @@ def get_catalog_ingestion_executor():
         definition_resolver=manager.definitions.get,
         collector_resolver=collector_resolver,
     )
+    active_metadata = ActiveMetadataService(
+        SqlAlchemyActiveMetadataRepository(db.session)
+    )
+
+    def project_active_metadata(job, snapshot_record):
+        active_metadata.execute_source_plans(
+            snapshot_record.source_uid,
+            snapshot_record.snapshot,
+            batch_key=f"catalog:{snapshot_record.uid}",
+            actor_uid=job.actor_uid,
+            cursor_after={
+                "catalog_snapshot_uid": snapshot_record.uid,
+                "content_hash": snapshot_record.content_hash,
+            },
+        )
+
     return CatalogIngestionExecutor(
         get_ingestion_service(),
         collector,
         get_catalog_snapshot_repository(),
         commit=db.session.commit,
         rollback=db.session.rollback,
+        on_snapshot=project_active_metadata,
     )
 
 

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

@@ -5,6 +5,7 @@ from flask import Blueprint
 bp = Blueprint("meta_data", __name__)
 
 from app.api.meta_data import (
+    active_metadata,
     domain_templates,
     routes,
 )

+ 154 - 0
app/api/meta_data/active_metadata.py

@@ -0,0 +1,154 @@
+"""HTTP API for active metadata discovery and field lineage."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.meta_data import bp
+from app.core.meta_data.active_metadata import (
+    ActiveMetadataConflict,
+    ActiveMetadataError,
+    ActiveMetadataNotFound,
+    ActiveMetadataService,
+)
+from app.core.meta_data.active_metadata_repository import (
+    SqlAlchemyActiveMetadataRepository,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    return ActiveMetadataService(SqlAlchemyActiveMetadataRepository(db.session))
+
+
+def _error(exc):
+    db.session.rollback()
+    if isinstance(exc, ActiveMetadataNotFound):
+        return jsonify(failed(str(exc), code=404)), 404
+    if isinstance(exc, ActiveMetadataConflict):
+        return jsonify(failed(str(exc), code=409)), 409
+    if isinstance(exc, ActiveMetadataError):
+        return jsonify(failed(str(exc), code=400)), 400
+    raise exc
+
+
+@bp.route("/active-metadata/plans", methods=["GET"])
+def list_active_metadata_plans():
+    return jsonify(success(_service().list_plans()))
+
+
+@bp.route("/active-metadata/plans", methods=["POST"])
+def create_active_metadata_plan():
+    try:
+        result = _service().create_plan(
+            request.get_json(silent=True),
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "主动发现计划已创建")), 201
+    except Exception as exc:
+        return _error(exc)
+
+@bp.route("/active-metadata/plans/<plan_uid>/runs", methods=["POST"])
+def execute_active_metadata_plan(plan_uid):
+    batch_key = str(request.headers.get("Idempotency-Key") or "").strip()
+    if not batch_key:
+        return jsonify(failed("Idempotency-Key is required", code=428)), 428
+    try:
+        result = _service().execute(
+            plan_uid,
+            request.get_json(silent=True),
+            batch_key=batch_key,
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "主动发现批次已完成"))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/plans/<plan_uid>/runs", methods=["GET"])
+def list_active_metadata_runs(plan_uid):
+    try:
+        return jsonify(success(_service().list_runs(plan_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/assets", methods=["GET"])
+def list_active_metadata_assets():
+    try:
+        return jsonify(
+            success(_service().list_assets(request.args.get("source_uid")))
+        )
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/runs/<run_uid>/changes", methods=["GET"])
+def list_active_metadata_changes(run_uid):
+    try:
+        return jsonify(success(_service().list_changes(run_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/runs/<run_uid>/lineage", methods=["GET"])
+def list_active_metadata_lineage(run_uid):
+    try:
+        return jsonify(success(_service().list_lineage(run_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/runs/<run_uid>/health-signals", methods=["GET"])
+def list_active_metadata_health(run_uid):
+    try:
+        return jsonify(success(_service().list_health_signals(run_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/corrections", methods=["GET"])
+def list_active_metadata_corrections():
+    try:
+        return jsonify(
+            success(_service().list_corrections(request.args.get("asset_uid")))
+        )
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/assets/<asset_uid>/corrections", methods=["POST"])
+def submit_active_metadata_correction(asset_uid):
+    try:
+        result = _service().submit_correction(
+            asset_uid,
+            request.get_json(silent=True),
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "元数据纠错已提交")), 201
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route(
+    "/active-metadata/corrections/<correction_uid>/resolve",
+    methods=["POST"],
+)
+def resolve_active_metadata_correction(correction_uid):
+    body = request.get_json(silent=True) or {}
+    try:
+        result = _service().resolve_correction(
+            correction_uid,
+            expected_version=body.get("expected_version"),
+            decision=body.get("decision"),
+            resolution=body.get("resolution") or {},
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "元数据纠错已处置"))
+    except Exception as exc:
+        return _error(exc)

+ 4 - 2
app/core/data_research/catalog/execution.py

@@ -1,11 +1,10 @@
 from __future__ import annotations
 
+from app.core.data_research.catalog.models import CatalogScope
 from app.core.data_research.errors import (
     IngestionPayloadInvalid,
     InvalidJobTransition,
 )
-from app.core.data_research.catalog.models import CatalogScope
-
 
 _SCOPE_FIELDS = (
     "include_schemas",
@@ -41,12 +40,14 @@ class CatalogIngestionExecutor:
         *,
         commit=lambda: None,
         rollback=lambda: None,
+        on_snapshot=lambda _job, _snapshot: None,
     ):
         self.ingestion = ingestion
         self.collector = collector
         self.snapshots = snapshots
         self.commit = commit
         self.rollback = rollback
+        self.on_snapshot = on_snapshot
 
     @staticmethod
     def _report(snapshot_record):
@@ -95,6 +96,7 @@ class CatalogIngestionExecutor:
                     job.attempt_count,
                     snapshot,
                 )
+                self.on_snapshot(job, snapshot_record)
                 self.commit()
             if job.status == "normalizing":
                 job = self.ingestion.transition(job.uid, "matching")

+ 703 - 0
app/core/meta_data/active_metadata.py

@@ -0,0 +1,703 @@
+"""Active metadata discovery, incremental change, lineage and correction contracts."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+import uuid
+from collections import Counter
+from copy import deepcopy
+from datetime import UTC, datetime
+from typing import Any
+
+import sqlglot
+from sqlglot import exp
+
+from app.core.common.identifiers import new_governance_uid
+
+SOURCE_KINDS = frozenset({"database", "file", "api"})
+SCHEDULE_TYPES = frozenset({"manual", "interval", "cron"})
+DISCOVERY_MODES = frozenset({"snapshot", "cursor"})
+HEALTH_SIGNAL_TYPES = frozenset({"quality", "freshness", "task_failure", "usage"})
+HEALTH_STATUSES = frozenset({"healthy", "warning", "critical", "unknown"})
+SECRET_MARKERS = ("password", "secret", "token", "credential", "api_key", "private_key")
+INTERVAL_RE = re.compile(r"^PT(?:[1-9]\d*[HMS])+$")
+CRON_PART_RE = re.compile(r"^[\d*/?,LW#-]+$")
+
+
+class ActiveMetadataError(ValueError):
+    pass
+
+
+class ActiveMetadataNotFound(LookupError):
+    pass
+
+
+class ActiveMetadataConflict(RuntimeError):
+    pass
+
+
+def _canonical(value: Any) -> str:
+    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+
+
+def _hash(value: Any) -> str:
+    return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
+
+
+def _reject_secrets(value: Any, path: str = "payload") -> None:
+    if isinstance(value, dict):
+        for key, nested in value.items():
+            normalized = str(key).lower().replace("-", "_")
+            if any(marker in normalized for marker in SECRET_MARKERS):
+                raise ActiveMetadataError(f"secret-bearing field is not allowed at {path}.{key}")
+            _reject_secrets(nested, f"{path}.{key}")
+    elif isinstance(value, list):
+        for index, nested in enumerate(value):
+            _reject_secrets(nested, f"{path}[{index}]")
+
+
+def _text(value: Any, field: str, limit: int = 300) -> str:
+    result = str(value or "").strip()
+    if not result or len(result) > limit:
+        raise ActiveMetadataError(f"{field} must be between 1 and {limit} characters")
+    return result
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (TypeError, ValueError, AttributeError) as exc:
+        raise ActiveMetadataError(f"{field} must be a UUID") from exc
+
+
+def _schedule(payload: dict[str, Any]) -> tuple[str, str | None]:
+    schedule_type = str(payload.get("schedule_type") or "manual").strip().lower()
+    if schedule_type not in SCHEDULE_TYPES:
+        raise ActiveMetadataError("schedule_type is unsupported")
+    expression = payload.get("schedule_expression")
+    expression = str(expression).strip() if expression is not None else None
+    if schedule_type == "manual":
+        if expression:
+            raise ActiveMetadataError("manual schedule cannot have schedule_expression")
+        return schedule_type, None
+    if schedule_type == "interval":
+        if not expression or not INTERVAL_RE.fullmatch(expression):
+            raise ActiveMetadataError("interval schedule_expression must be ISO-8601 PT duration")
+        return schedule_type, expression
+    parts = expression.split() if expression else []
+    if len(parts) != 5 or any(not CRON_PART_RE.fullmatch(part) for part in parts):
+        raise ActiveMetadataError("cron schedule_expression must contain five valid fields")
+    return schedule_type, expression
+
+
+def _table_name(table: exp.Table) -> str:
+    return ".".join(part for part in (table.catalog, table.db, table.name) if part)
+
+
+def parse_sql_field_lineage(sql: str, *, dialect: str | None = None) -> dict[str, Any]:
+    """Parse conservative field lineage and preserve unsupported SQL as evidence."""
+    evidence = str(sql or "").strip()
+    if not evidence:
+        return {"status": "failed", "edges": [], "failure_reason": "SQL is empty"}
+    try:
+        statement = sqlglot.parse_one(evidence, read=dialect)
+        if not isinstance(statement, exp.Insert):
+            raise ActiveMetadataError("only INSERT ... SELECT lineage is supported")
+        target_schema = statement.this
+        if not isinstance(target_schema, exp.Schema) or not isinstance(
+            target_schema.this, exp.Table
+        ):
+            raise ActiveMetadataError("INSERT target columns are required")
+        select = statement.expression
+        if not isinstance(select, exp.Select):
+            raise ActiveMetadataError("INSERT source must be a SELECT")
+        targets = [item.name for item in target_schema.expressions]
+        if len(targets) != len(select.expressions):
+            raise ActiveMetadataError("target and select field counts differ")
+        tables = list(select.find_all(exp.Table))
+        aliases = {}
+        for table in tables:
+            name = _table_name(table)
+            aliases[table.alias_or_name] = name
+            aliases[table.name] = name
+        edges = []
+        for target_field, expression in zip(targets, select.expressions, strict=True):
+            columns = list(expression.find_all(exp.Column))
+            if not columns and isinstance(expression, exp.Column):
+                columns = [expression]
+            for column in columns:
+                if column.table:
+                    source_asset = aliases.get(column.table)
+                else:
+                    source_asset = _table_name(tables[0]) if len(tables) == 1 else None
+                if not source_asset:
+                    raise ActiveMetadataError(
+                        f"cannot resolve source table for field {column.name}"
+                    )
+                edges.append(
+                    {
+                        "source_asset": source_asset,
+                        "source_field": column.name,
+                        "target_asset": _table_name(target_schema.this),
+                        "target_field": target_field,
+                        "relation_type": "derived_from",
+                        "evidence": {"kind": "sql", "statement_hash": _hash(evidence)},
+                    }
+                )
+        if not edges:
+            raise ActiveMetadataError("no field lineage was resolved")
+        return {"status": "resolved", "edges": edges, "failure_reason": None}
+    except Exception as exc:
+        return {
+            "status": "failed",
+            "edges": [],
+            "failure_reason": str(exc)[:500],
+        }
+
+
+def _normalize_fields(asset_key: str, fields: Any) -> list[dict[str, Any]]:
+    if fields is None:
+        fields = []
+    if not isinstance(fields, list) or len(fields) > 5000:
+        raise ActiveMetadataError(f"{asset_key}.fields must be a bounded list")
+    result = []
+    seen = set()
+    for index, field in enumerate(fields, 1):
+        if not isinstance(field, dict):
+            raise ActiveMetadataError(f"{asset_key}.fields items must be objects")
+        name = _text(field.get("name"), f"{asset_key}.field.name", 200)
+        if name in seen:
+            raise ActiveMetadataError(f"duplicate field {asset_key}.{name}")
+        seen.add(name)
+        result.append(
+            {
+                "name": name,
+                "data_type": _text(
+                    field.get("data_type") or "unknown",
+                    f"{asset_key}.{name}.data_type",
+                    100,
+                ),
+                "nullable": bool(field.get("nullable", True)),
+                "ordinal_position": int(field.get("ordinal_position") or index),
+                "default": field.get("default"),
+                "comment": field.get("comment"),
+            }
+        )
+    return sorted(result, key=lambda item: (item["ordinal_position"], item["name"]))
+
+
+def normalize_snapshot(source_uid: str, snapshot: Any) -> list[dict[str, Any]]:
+    if not isinstance(snapshot, dict) or not isinstance(snapshot.get("assets"), list):
+        raise ActiveMetadataError("snapshot.assets must be a list")
+    if len(snapshot["assets"]) > 10000:
+        raise ActiveMetadataError("snapshot contains too many assets")
+    result = []
+    seen = set()
+    for item in snapshot["assets"]:
+        if not isinstance(item, dict):
+            raise ActiveMetadataError("snapshot asset must be an object")
+        namespace = _text(
+            item.get("namespace") or item.get("schema") or "default",
+            "asset.namespace",
+            200,
+        )
+        name = _text(item.get("name"), "asset.name", 200)
+        key = str(item.get("key") or f"{source_uid}:{namespace}.{name}").strip()
+        if not key.startswith(f"{source_uid}:"):
+            raise ActiveMetadataError("asset key must be scoped to source_uid")
+        if key in seen:
+            raise ActiveMetadataError(f"duplicate asset key {key}")
+        seen.add(key)
+        normalized = {
+            "key": key,
+            "namespace": namespace,
+            "name": name,
+            "asset_type": _text(
+                item.get("asset_type") or "resource", "asset.asset_type", 40
+            ),
+            "comment": item.get("comment"),
+            "fields": _normalize_fields(key, item.get("fields")),
+        }
+        normalized["content_hash"] = _hash(normalized)
+        result.append(normalized)
+    return sorted(result, key=lambda item: item["key"])
+
+
+def _stable_asset_uid(source_uid: str, asset_key: str) -> str:
+    return str(
+        uuid.uuid5(
+            uuid.NAMESPACE_URL,
+            f"dataops-active-metadata:{source_uid}:{asset_key}",
+        )
+    )
+
+
+class ActiveMetadataService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory=new_governance_uid,
+        now_factory=lambda: datetime.now(UTC),
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+
+    def create_plan(self, payload: Any, *, actor_uid: str):
+        if not isinstance(payload, dict):
+            raise ActiveMetadataError("plan payload must be an object")
+        _reject_secrets(payload)
+        source_kind = str(payload.get("source_kind") or "").strip().lower()
+        if source_kind not in SOURCE_KINDS:
+            raise ActiveMetadataError("source_kind is unsupported")
+        discovery_mode = str(payload.get("discovery_mode") or "snapshot").strip().lower()
+        if discovery_mode not in DISCOVERY_MODES:
+            raise ActiveMetadataError("discovery_mode is unsupported")
+        schedule_type, schedule_expression = _schedule(payload)
+        scope = deepcopy(payload.get("scope") or {})
+        if not isinstance(scope, dict):
+            raise ActiveMetadataError("scope must be an object")
+        plan = {
+            "uid": self.uid_factory(),
+            "source_uid": _uuid(payload.get("source_uid"), "source_uid"),
+            "name": _text(payload.get("name"), "name"),
+            "source_kind": source_kind,
+            "schedule_type": schedule_type,
+            "schedule_expression": schedule_expression,
+            "discovery_mode": discovery_mode,
+            "scope": scope,
+            "cursor_state": {},
+            "owner_uid": _uuid(payload.get("owner_uid"), "owner_uid"),
+            "enabled": bool(payload.get("enabled", True)),
+            "current_version": 1,
+            "created_by": _uuid(actor_uid, "actor_uid"),
+            "created_at": self.now_factory().isoformat(),
+        }
+        return self.repository.save_plan(plan)
+
+    def list_plans(self):
+        return self.repository.list_plans()
+
+    def execute_source_plans(
+        self,
+        source_uid: str,
+        snapshot: dict[str, Any],
+        *,
+        batch_key: str,
+        actor_uid: str,
+        cursor_after: dict[str, Any] | None = None,
+    ):
+        source_uid = _uuid(source_uid, "source_uid")
+        results = []
+        for plan in self.list_plans():
+            if (
+                plan["source_uid"] != source_uid
+                or plan["source_kind"] != "database"
+                or not plan["enabled"]
+            ):
+                continue
+            results.append(
+                self.execute(
+                    plan["uid"],
+                    {
+                        "snapshot": deepcopy(snapshot),
+                        "cursor_after": deepcopy(cursor_after or {}),
+                    },
+                    batch_key=batch_key,
+                    actor_uid=actor_uid,
+                )
+            )
+        return results
+
+    def get_plan(self, uid: str):
+        result = self.repository.get_plan(_uuid(uid, "plan_uid"))
+        if result is None:
+            raise ActiveMetadataNotFound("active metadata plan was not found")
+        return result
+
+    def list_assets(self, source_uid: str):
+        return self.repository.list_assets(_uuid(source_uid, "source_uid"))
+
+    def list_runs(self, plan_uid: str):
+        self.get_plan(plan_uid)
+        return self.repository.list_runs(plan_uid)
+
+    def list_changes(self, run_uid: str):
+        return self.repository.list_changes(_uuid(run_uid, "run_uid"))
+
+    def list_lineage(self, run_uid: str):
+        return self.repository.list_lineage(_uuid(run_uid, "run_uid"))
+
+    def list_health_signals(self, run_uid: str):
+        return self.repository.list_health_signals(_uuid(run_uid, "run_uid"))
+
+    def list_corrections(self, asset_uid: str | None = None):
+        return self.repository.list_corrections(
+            _uuid(asset_uid, "asset_uid") if asset_uid else None
+        )
+
+    def execute(
+        self,
+        plan_uid: str,
+        payload: Any,
+        *,
+        batch_key: str,
+        actor_uid: str,
+    ):
+        plan = self.get_plan(plan_uid)
+        batch_key = _text(batch_key, "batch_key", 160)
+        existing = self.repository.find_run_by_batch(plan["uid"], batch_key)
+        if existing is not None:
+            return existing
+        if not isinstance(payload, dict):
+            raise ActiveMetadataError("run payload must be an object")
+        _reject_secrets(payload)
+        current_assets = normalize_snapshot(plan["source_uid"], payload.get("snapshot"))
+        previous = {item["asset_key"]: item for item in self.repository.list_assets(plan["source_uid"])}
+        current = {item["key"]: item for item in current_assets}
+        run_uid = self.uid_factory()
+        now = self.now_factory().isoformat()
+        assets = []
+        versions = []
+        changes = []
+
+        for key, snapshot in current.items():
+            prior = previous.get(key)
+            asset_uid = prior["uid"] if prior else _stable_asset_uid(plan["source_uid"], key)
+            version = int(prior["current_version"]) if prior else 0
+            changed = prior is None or prior["content_hash"] != snapshot["content_hash"]
+            if changed:
+                version += 1
+                versions.append(
+                    {
+                        "uid": self.uid_factory(),
+                        "asset_uid": asset_uid,
+                        "version": version,
+                        "run_uid": run_uid,
+                        "content_hash": snapshot["content_hash"],
+                        "snapshot": deepcopy(snapshot),
+                        "actor_uid": _uuid(actor_uid, "actor_uid"),
+                        "created_at": now,
+                    }
+                )
+            asset = {
+                "uid": asset_uid,
+                "source_uid": plan["source_uid"],
+                "asset_key": key,
+                "namespace": snapshot["namespace"],
+                "name": snapshot["name"],
+                "asset_type": snapshot["asset_type"],
+                "lifecycle_status": "active",
+                "current_version": max(version, 1),
+                "content_hash": snapshot["content_hash"],
+                "snapshot": deepcopy(snapshot),
+                "health": deepcopy(prior.get("health", {}) if prior else {}),
+                "last_run_uid": run_uid,
+                "updated_at": now,
+            }
+            assets.append(asset)
+            if prior is None:
+                changes.append(
+                    self._change(run_uid, asset, "asset_added", None, None, snapshot)
+                )
+            elif changed:
+                changes.extend(self._field_changes(run_uid, asset, prior["snapshot"], snapshot))
+
+        for key in sorted(set(previous) - set(current)):
+            prior = deepcopy(previous[key])
+            prior["lifecycle_status"] = "deletion_candidate"
+            prior["last_run_uid"] = run_uid
+            prior["updated_at"] = now
+            assets.append(prior)
+            changes.append(
+                self._change(
+                    run_uid,
+                    prior,
+                    "deletion_candidate",
+                    None,
+                    prior["snapshot"],
+                    None,
+                )
+            )
+
+        health_signals = self._health_signals(
+            payload.get("health_signals") or [],
+            assets,
+            run_uid,
+            now,
+        )
+        lineage = self._lineage(payload.get("lineage_sql") or [], run_uid, now)
+        counts = Counter(item["change_type"] for item in changes)
+        run = {
+            "uid": run_uid,
+            "plan_uid": plan["uid"],
+            "batch_key": batch_key,
+            "status": "completed",
+            "attempt_count": 1,
+            "cursor_before": deepcopy(plan.get("cursor_state") or {}),
+            "cursor_after": deepcopy(payload.get("cursor_after") or {}),
+            "snapshot_hash": _hash(current_assets),
+            "statistics": dict(counts),
+            "failure_code": None,
+            "failure_reason": None,
+            "actor_uid": _uuid(actor_uid, "actor_uid"),
+            "started_at": now,
+            "finished_at": now,
+        }
+        return self.repository.apply_discovery(
+            {
+                "run": run,
+                "assets": assets,
+                "versions": versions,
+                "changes": changes,
+                "lineage": lineage,
+                "health_signals": health_signals,
+            }
+        )
+
+    def _change(self, run_uid, asset, change_type, field_name, before, after):
+        return {
+            "uid": self.uid_factory(),
+            "run_uid": run_uid,
+            "asset_uid": asset["uid"],
+            "asset_key": asset["asset_key"],
+            "field_name": field_name,
+            "change_type": change_type,
+            "before_state": deepcopy(before),
+            "after_state": deepcopy(after),
+            "status": "pending",
+        }
+
+    def _field_changes(self, run_uid, asset, before, after):
+        before_fields = {item["name"]: item for item in before.get("fields", [])}
+        after_fields = {item["name"]: item for item in after.get("fields", [])}
+        changes = []
+        for name in sorted(set(after_fields) - set(before_fields)):
+            changes.append(
+                self._change(run_uid, asset, "field_added", name, None, after_fields[name])
+            )
+        for name in sorted(set(before_fields) - set(after_fields)):
+            changes.append(
+                self._change(
+                    run_uid,
+                    asset,
+                    "field_deletion_candidate",
+                    name,
+                    before_fields[name],
+                    None,
+                )
+            )
+        for name in sorted(set(before_fields) & set(after_fields)):
+            if before_fields[name] != after_fields[name]:
+                changes.append(
+                    self._change(
+                        run_uid,
+                        asset,
+                        "field_changed",
+                        name,
+                        before_fields[name],
+                        after_fields[name],
+                    )
+                )
+        return changes
+
+    def _health_signals(self, raw_signals, assets, run_uid, now):
+        if not isinstance(raw_signals, list):
+            raise ActiveMetadataError("health_signals must be a list")
+        by_key = {item["asset_key"]: item for item in assets}
+        result = []
+        for raw in raw_signals:
+            if not isinstance(raw, dict):
+                raise ActiveMetadataError("health signal must be an object")
+            asset = by_key.get(str(raw.get("asset_key") or ""))
+            if asset is None:
+                raise ActiveMetadataError("health signal asset_key is unknown")
+            signal_type = str(raw.get("signal_type") or "").lower()
+            status = str(raw.get("status") or "unknown").lower()
+            if signal_type not in HEALTH_SIGNAL_TYPES or status not in HEALTH_STATUSES:
+                raise ActiveMetadataError("health signal type or status is unsupported")
+            signal = {
+                "uid": self.uid_factory(),
+                "asset_uid": asset["uid"],
+                "run_uid": run_uid,
+                "signal_type": signal_type,
+                "value": raw.get("value"),
+                "status": status,
+                "evidence": deepcopy(raw.get("evidence") or {}),
+                "observed_at": str(raw.get("observed_at") or now),
+            }
+            asset["health"][signal_type] = {
+                "value": signal["value"],
+                "status": status,
+                "observed_at": signal["observed_at"],
+            }
+            result.append(signal)
+        return result
+
+    def _lineage(self, statements, run_uid, now):
+        if not isinstance(statements, list):
+            raise ActiveMetadataError("lineage_sql must be a list")
+        records = []
+        for item in statements:
+            if isinstance(item, str):
+                sql, dialect = item, None
+            elif isinstance(item, dict):
+                sql, dialect = item.get("sql"), item.get("dialect")
+            else:
+                raise ActiveMetadataError("lineage_sql item must be text or object")
+            parsed = parse_sql_field_lineage(sql, dialect=dialect)
+            if parsed["status"] == "failed":
+                records.append(
+                    {
+                        "uid": self.uid_factory(),
+                        "run_uid": run_uid,
+                        "parse_status": "failed",
+                        "source_asset": None,
+                        "source_field": None,
+                        "target_asset": None,
+                        "target_field": None,
+                        "relation_type": "derived_from",
+                        "evidence": {"statement_hash": _hash(str(sql or ""))},
+                        "failure_reason": parsed["failure_reason"],
+                        "created_at": now,
+                    }
+                )
+            for edge in parsed["edges"]:
+                records.append(
+                    {
+                        "uid": self.uid_factory(),
+                        "run_uid": run_uid,
+                        "parse_status": "resolved",
+                        **edge,
+                        "failure_reason": None,
+                        "created_at": now,
+                    }
+                )
+        return records
+
+    def record_failure(
+        self,
+        plan_uid: str,
+        *,
+        batch_key: str,
+        error_code: str,
+        failure_reason: str,
+        actor_uid: str,
+    ):
+        plan = self.get_plan(plan_uid)
+        existing = self.repository.find_run_by_batch(plan["uid"], batch_key)
+        if existing is not None:
+            return existing
+        now = self.now_factory().isoformat()
+        run_uid = self.uid_factory()
+        run = {
+            "uid": run_uid,
+            "plan_uid": plan["uid"],
+            "batch_key": _text(batch_key, "batch_key", 160),
+            "status": "failed",
+            "attempt_count": 1,
+            "cursor_before": deepcopy(plan.get("cursor_state") or {}),
+            "cursor_after": deepcopy(plan.get("cursor_state") or {}),
+            "snapshot_hash": None,
+            "statistics": {},
+            "failure_code": _text(error_code, "error_code", 80),
+            "failure_reason": _text(failure_reason, "failure_reason", 500),
+            "actor_uid": _uuid(actor_uid, "actor_uid"),
+            "started_at": now,
+            "finished_at": now,
+        }
+        signals = [
+            {
+                "uid": self.uid_factory(),
+                "asset_uid": asset["uid"],
+                "run_uid": run_uid,
+                "signal_type": "task_failure",
+                "value": 1,
+                "status": "critical",
+                "evidence": {"error_code": run["failure_code"]},
+                "observed_at": now,
+            }
+            for asset in self.repository.list_assets(plan["source_uid"])
+        ]
+        return self.repository.record_failed_run(run, signals)
+
+    def submit_correction(self, asset_uid: str, payload: Any, *, actor_uid: str):
+        asset = self.repository.get_asset(_uuid(asset_uid, "asset_uid"))
+        if asset is None:
+            raise ActiveMetadataNotFound("active metadata asset was not found")
+        if not isinstance(payload, dict):
+            raise ActiveMetadataError("correction payload must be an object")
+        _reject_secrets(payload)
+        now = self.now_factory().isoformat()
+        correction = {
+            "uid": self.uid_factory(),
+            "asset_uid": asset["uid"],
+            "field_name": str(payload.get("field_name") or "").strip() or None,
+            "proposed_value": deepcopy(payload.get("proposed_value") or {}),
+            "reason": _text(payload.get("reason"), "reason", 500),
+            "assignee_uid": _uuid(payload.get("assignee_uid"), "assignee_uid"),
+            "status": "pending",
+            "resolution": {},
+            "current_version": 1,
+            "submitted_by": _uuid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "updated_at": now,
+        }
+        return self.repository.save_correction(
+            correction,
+            self._correction_audit(correction, "correction_submitted", actor_uid, None),
+        )
+
+    def resolve_correction(
+        self,
+        correction_uid: str,
+        *,
+        expected_version: int,
+        decision: str,
+        resolution: dict[str, Any],
+        actor_uid: str,
+    ):
+        correction = self.repository.get_correction(_uuid(correction_uid, "correction_uid"))
+        if correction is None:
+            raise ActiveMetadataNotFound("active metadata correction was not found")
+        if int(correction["current_version"]) != int(expected_version):
+            raise ActiveMetadataConflict("correction version conflict")
+        actor_uid = _uuid(actor_uid, "actor_uid")
+        if actor_uid != correction["assignee_uid"]:
+            raise ActiveMetadataError("only the correction assignee may resolve it")
+        if decision not in {"accept", "reject"}:
+            raise ActiveMetadataError("decision must be accept or reject")
+        before = deepcopy(correction)
+        correction.update(
+            {
+                "status": "resolved" if decision == "accept" else "rejected",
+                "resolution": deepcopy(resolution or {}),
+                "current_version": int(correction["current_version"]) + 1,
+                "updated_at": self.now_factory().isoformat(),
+                "resolved_by": actor_uid,
+            }
+        )
+        return self.repository.resolve_correction(
+            correction,
+            self._correction_audit(
+                correction,
+                "correction_resolved",
+                actor_uid,
+                before,
+            ),
+        )
+
+    def _correction_audit(self, correction, action, actor_uid, before):
+        return {
+            "uid": self.uid_factory(),
+            "correction_uid": correction["uid"],
+            "version": correction["current_version"],
+            "action": action,
+            "before_state": deepcopy(before),
+            "after_state": deepcopy(correction),
+            "actor_uid": _uuid(actor_uid, "actor_uid"),
+            "created_at": self.now_factory().isoformat(),
+        }

+ 500 - 0
app/core/meta_data/active_metadata_repository.py

@@ -0,0 +1,500 @@
+"""PostgreSQL repository for active metadata discovery."""
+
+from __future__ import annotations
+
+import json
+import uuid
+
+from sqlalchemy import text
+
+
+def _json(value):
+    return json.dumps(value, ensure_ascii=False, sort_keys=True)
+
+
+def _row(row):
+    if row is None:
+        return None
+    result = dict(row)
+    for key, value in tuple(result.items()):
+        if isinstance(value, uuid.UUID):
+            result[key] = str(value)
+        elif hasattr(value, "isoformat"):
+            result[key] = value.isoformat()
+        elif isinstance(value, dict):
+            result[key] = dict(value)
+    return result
+
+
+class SqlAlchemyActiveMetadataRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def save_plan(self, plan):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.active_metadata_plans (
+                    uid, source_uid, name, source_kind, schedule_type,
+                    schedule_expression, discovery_mode, scope, cursor_state,
+                    owner_uid, enabled, current_version, created_by, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:source_uid AS uuid), :name,
+                    :source_kind, :schedule_type, :schedule_expression,
+                    :discovery_mode, CAST(:scope AS jsonb),
+                    CAST(:cursor_state AS jsonb), CAST(:owner_uid AS uuid),
+                    :enabled, :current_version, CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **plan,
+                "scope": _json(plan["scope"]),
+                "cursor_state": _json(plan["cursor_state"]),
+            },
+        )
+        return self.get_plan(plan["uid"])
+
+    def get_plan(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, source_uid, name, source_kind, schedule_type,
+                           schedule_expression, discovery_mode, scope,
+                           cursor_state, owner_uid, enabled, current_version,
+                           created_by, created_at, updated_at
+                    FROM public.active_metadata_plans
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def list_plans(self):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, source_uid, name, source_kind, schedule_type,
+                           schedule_expression, discovery_mode, scope,
+                           cursor_state, owner_uid, enabled, current_version,
+                           created_by, created_at, updated_at
+                    FROM public.active_metadata_plans
+                    ORDER BY created_at DESC, uid DESC
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        ]
+
+    def find_run_by_batch(self, plan_uid, batch_key):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, plan_uid, batch_key, status, attempt_count,
+                           cursor_before, cursor_after, snapshot_hash,
+                           statistics, failure_code, failure_reason, actor_uid,
+                           started_at, finished_at, created_at
+                    FROM public.active_metadata_runs
+                    WHERE plan_uid = CAST(:plan_uid AS uuid)
+                      AND batch_key = :batch_key
+                    """
+                ),
+                {"plan_uid": plan_uid, "batch_key": batch_key},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def list_assets(self, source_uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, source_uid, asset_key, namespace, name,
+                           asset_type, lifecycle_status, current_version,
+                           content_hash, snapshot, health, last_run_uid,
+                           created_at, updated_at
+                    FROM public.active_metadata_assets
+                    WHERE source_uid = CAST(:source_uid AS uuid)
+                    ORDER BY namespace, name
+                    """
+                ),
+                {"source_uid": source_uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def get_asset(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, source_uid, asset_key, namespace, name,
+                           asset_type, lifecycle_status, current_version,
+                           content_hash, snapshot, health, last_run_uid,
+                           created_at, updated_at
+                    FROM public.active_metadata_assets
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def apply_discovery(self, operation):
+        run = operation["run"]
+        self._insert_run(run)
+        for asset in operation["assets"]:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_assets (
+                        uid, source_uid, asset_key, namespace, name,
+                        asset_type, lifecycle_status, current_version,
+                        content_hash, snapshot, health, last_run_uid,
+                        updated_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:source_uid AS uuid),
+                        :asset_key, :namespace, :name, :asset_type,
+                        :lifecycle_status, :current_version, :content_hash,
+                        CAST(:snapshot AS jsonb), CAST(:health AS jsonb),
+                        CAST(:last_run_uid AS uuid),
+                        CAST(:updated_at AS timestamptz)
+                    )
+                    ON CONFLICT (source_uid, asset_key) DO UPDATE SET
+                        namespace = EXCLUDED.namespace,
+                        name = EXCLUDED.name,
+                        asset_type = EXCLUDED.asset_type,
+                        lifecycle_status = EXCLUDED.lifecycle_status,
+                        current_version = EXCLUDED.current_version,
+                        content_hash = EXCLUDED.content_hash,
+                        snapshot = EXCLUDED.snapshot,
+                        health = EXCLUDED.health,
+                        last_run_uid = EXCLUDED.last_run_uid,
+                        updated_at = EXCLUDED.updated_at
+                    """
+                ),
+                {
+                    **asset,
+                    "snapshot": _json(asset["snapshot"]),
+                    "health": _json(asset["health"]),
+                },
+            )
+        for version in operation["versions"]:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_asset_versions (
+                        uid, asset_uid, version, run_uid, content_hash,
+                        snapshot, actor_uid, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:asset_uid AS uuid), :version,
+                        CAST(:run_uid AS uuid), :content_hash,
+                        CAST(:snapshot AS jsonb), CAST(:actor_uid AS uuid),
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                {**version, "snapshot": _json(version["snapshot"])},
+            )
+        for change in operation["changes"]:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_changes (
+                        uid, run_uid, asset_uid, asset_key, field_name,
+                        change_type, before_state, after_state, status
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:run_uid AS uuid),
+                        CAST(:asset_uid AS uuid), :asset_key, :field_name,
+                        :change_type, CAST(:before_state AS jsonb),
+                        CAST(:after_state AS jsonb), :status
+                    )
+                    """
+                ),
+                {
+                    **change,
+                    "before_state": (
+                        _json(change["before_state"])
+                        if change["before_state"] is not None
+                        else None
+                    ),
+                    "after_state": (
+                        _json(change["after_state"])
+                        if change["after_state"] is not None
+                        else None
+                    ),
+                },
+            )
+        for lineage in operation["lineage"]:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_lineage (
+                        uid, run_uid, parse_status, source_asset, source_field,
+                        target_asset, target_field, relation_type, evidence,
+                        failure_reason, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:run_uid AS uuid),
+                        :parse_status, :source_asset, :source_field,
+                        :target_asset, :target_field, :relation_type,
+                        CAST(:evidence AS jsonb), :failure_reason,
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                {**lineage, "evidence": _json(lineage["evidence"])},
+            )
+        for signal in operation["health_signals"]:
+            self._insert_signal(signal)
+        self.session.execute(
+            text(
+                """
+                UPDATE public.active_metadata_plans
+                SET cursor_state = CAST(:cursor_state AS jsonb),
+                    updated_at = CURRENT_TIMESTAMP
+                WHERE uid = CAST(:uid AS uuid)
+                """
+            ),
+            {"uid": run["plan_uid"], "cursor_state": _json(run["cursor_after"])},
+        )
+        return self.find_run_by_batch(run["plan_uid"], run["batch_key"])
+
+    def _insert_run(self, run):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.active_metadata_runs (
+                    uid, plan_uid, batch_key, status, attempt_count,
+                    cursor_before, cursor_after, snapshot_hash, statistics,
+                    failure_code, failure_reason, actor_uid,
+                    started_at, finished_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:plan_uid AS uuid), :batch_key,
+                    :status, :attempt_count, CAST(:cursor_before AS jsonb),
+                    CAST(:cursor_after AS jsonb), :snapshot_hash,
+                    CAST(:statistics AS jsonb), :failure_code, :failure_reason,
+                    CAST(:actor_uid AS uuid),
+                    CAST(:started_at AS timestamptz),
+                    CAST(:finished_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **run,
+                "cursor_before": _json(run["cursor_before"]),
+                "cursor_after": _json(run["cursor_after"]),
+                "statistics": _json(run["statistics"]),
+            },
+        )
+
+    def _insert_signal(self, signal):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.active_metadata_health_signals (
+                    uid, asset_uid, run_uid, signal_type, value,
+                    status, evidence, observed_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:asset_uid AS uuid),
+                    CAST(:run_uid AS uuid), :signal_type,
+                    CAST(:value AS jsonb), :status,
+                    CAST(:evidence AS jsonb),
+                    CAST(:observed_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **signal,
+                "value": _json(signal["value"]),
+                "evidence": _json(signal["evidence"]),
+            },
+        )
+
+    def record_failed_run(self, run, signals):
+        self._insert_run(run)
+        for signal in signals:
+            self._insert_signal(signal)
+        return self.find_run_by_batch(run["plan_uid"], run["batch_key"])
+
+    def save_correction(self, correction, audit):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.active_metadata_corrections (
+                    uid, asset_uid, field_name, proposed_value, reason,
+                    assignee_uid, status, resolution, current_version,
+                    submitted_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:asset_uid AS uuid), :field_name,
+                    CAST(:proposed_value AS jsonb), :reason,
+                    CAST(:assignee_uid AS uuid), :status,
+                    CAST(:resolution AS jsonb), :current_version,
+                    CAST(:submitted_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **correction,
+                "proposed_value": _json(correction["proposed_value"]),
+                "resolution": _json(correction["resolution"]),
+            },
+        )
+        self._insert_correction_audit(audit)
+        return self.get_correction(correction["uid"])
+
+    def get_correction(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, asset_uid, field_name, proposed_value, reason,
+                           assignee_uid, status, resolution, current_version,
+                           submitted_by, resolved_by, created_at, updated_at
+                    FROM public.active_metadata_corrections
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def resolve_correction(self, correction, audit):
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.active_metadata_corrections
+                SET status = :status,
+                    resolution = CAST(:resolution AS jsonb),
+                    current_version = :current_version,
+                    resolved_by = CAST(:resolved_by AS uuid),
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :expected_version
+                """
+            ),
+            {
+                **correction,
+                "resolution": _json(correction["resolution"]),
+                "expected_version": int(correction["current_version"]) - 1,
+            },
+        )
+        if result.rowcount != 1:
+            raise RuntimeError("correction version conflict")
+        self._insert_correction_audit(audit)
+        return self.get_correction(correction["uid"])
+
+    def _insert_correction_audit(self, audit):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.active_metadata_correction_audits (
+                    uid, correction_uid, version, action, before_state,
+                    after_state, actor_uid, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:correction_uid AS uuid),
+                    :version, :action, CAST(:before_state AS jsonb),
+                    CAST(:after_state AS jsonb), CAST(:actor_uid AS uuid),
+                    CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **audit,
+                "before_state": (
+                    _json(audit["before_state"])
+                    if audit["before_state"] is not None
+                    else None
+                ),
+                "after_state": _json(audit["after_state"]),
+            },
+        )
+
+    def list_runs(self, plan_uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, plan_uid, batch_key, status, attempt_count,
+                           cursor_before, cursor_after, snapshot_hash,
+                           statistics, failure_code, failure_reason, actor_uid,
+                           started_at, finished_at, created_at
+                    FROM public.active_metadata_runs
+                    WHERE plan_uid = CAST(:plan_uid AS uuid)
+                    ORDER BY created_at DESC, uid DESC
+                    """
+                ),
+                {"plan_uid": plan_uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_changes(self, run_uid):
+        return self._list_by_run("active_metadata_changes", run_uid)
+
+    def list_lineage(self, run_uid):
+        return self._list_by_run("active_metadata_lineage", run_uid)
+
+    def list_health_signals(self, run_uid):
+        return self._list_by_run("active_metadata_health_signals", run_uid)
+
+    def _list_by_run(self, table, run_uid):
+        allowed = {
+            "active_metadata_changes",
+            "active_metadata_lineage",
+            "active_metadata_health_signals",
+        }
+        if table not in allowed:
+            raise ValueError("unsupported active metadata table")
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    f"SELECT * FROM public.{table} "
+                    "WHERE run_uid = CAST(:run_uid AS uuid) "
+                    "ORDER BY created_at, uid"
+                ),
+                {"run_uid": run_uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_corrections(self, asset_uid=None):
+        where = (
+            "WHERE asset_uid = CAST(:asset_uid AS uuid)" if asset_uid else ""
+        )
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    "SELECT uid, asset_uid, field_name, proposed_value, reason, "
+                    "assignee_uid, status, resolution, current_version, "
+                    "submitted_by, resolved_by, created_at, updated_at "
+                    f"FROM public.active_metadata_corrections {where} "
+                    "ORDER BY created_at DESC, uid DESC"
+                ),
+                {"asset_uid": asset_uid} if asset_uid else {},
+            )
+            .mappings()
+            .all()
+        ]

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

@@ -49,6 +49,9 @@ GOVERNANCE_AUDIT_SEAL = "governance-audit:seal"
 DOMAIN_TEMPLATES_READ = "domain-templates:read"
 DOMAIN_TEMPLATES_PREVIEW = "domain-templates:preview"
 DOMAIN_TEMPLATES_MANAGE = "domain-templates:manage"
+ACTIVE_METADATA_READ = "active-metadata:read"
+ACTIVE_METADATA_OPERATE = "active-metadata:operate"
+ACTIVE_METADATA_MANAGE = "active-metadata:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -57,6 +60,7 @@ ROLE_PERMISSIONS = {
             RULES_READ,
             RESPONSIBILITIES_READ,
             DOMAIN_TEMPLATES_READ,
+            ACTIVE_METADATA_READ,
         }
     ),
     "editor": frozenset(
@@ -81,6 +85,8 @@ ROLE_PERMISSIONS = {
             DEVICE_OBSERVABILITY_EDIT,
             DOMAIN_TEMPLATES_READ,
             DOMAIN_TEMPLATES_PREVIEW,
+            ACTIVE_METADATA_READ,
+            ACTIVE_METADATA_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -127,6 +133,9 @@ ROLE_PERMISSIONS = {
             DOMAIN_TEMPLATES_READ,
             DOMAIN_TEMPLATES_PREVIEW,
             DOMAIN_TEMPLATES_MANAGE,
+            ACTIVE_METADATA_READ,
+            ACTIVE_METADATA_OPERATE,
+            ACTIVE_METADATA_MANAGE,
         }
     ),
 }
@@ -153,6 +162,12 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path == "/api/meta/domain-templates/dry-run":
             return (DOMAIN_TEMPLATES_PREVIEW,)
         return (DOMAIN_TEMPLATES_MANAGE,)
+    if path.startswith("/api/meta/active-metadata"):
+        if method == "GET":
+            return (ACTIVE_METADATA_READ,)
+        if path == "/api/meta/active-metadata/plans":
+            return (ACTIVE_METADATA_MANAGE,)
+        return (ACTIVE_METADATA_OPERATE,)
     if path in {"/api/knowledge/search", "/api/knowledge/ask"}:
         return (READ_GOVERNANCE,)
     if path.startswith("/api/rules"):

+ 20 - 0
app/models/__init__.py

@@ -1,5 +1,16 @@
 # Models package initialization
 
+from app.models.active_metadata import (
+    ActiveMetadataAsset,
+    ActiveMetadataAssetVersion,
+    ActiveMetadataChange,
+    ActiveMetadataCorrection,
+    ActiveMetadataCorrectionAudit,
+    ActiveMetadataHealthSignal,
+    ActiveMetadataLineage,
+    ActiveMetadataPlan,
+    ActiveMetadataRun,
+)
 from app.models.data_product import DataOrder, DataProduct
 from app.models.data_research import (
     CandidateDecisionRecord,
@@ -25,6 +36,15 @@ from app.models.governance_template import (
 from app.models.metadata_review import MetadataReviewRecord, MetadataVersionHistory
 
 __all__ = [
+    "ActiveMetadataPlan",
+    "ActiveMetadataRun",
+    "ActiveMetadataAsset",
+    "ActiveMetadataAssetVersion",
+    "ActiveMetadataChange",
+    "ActiveMetadataLineage",
+    "ActiveMetadataHealthSignal",
+    "ActiveMetadataCorrection",
+    "ActiveMetadataCorrectionAudit",
     "DataOrder",
     "DataProduct",
     "MetadataReviewRecord",

+ 160 - 0
app/models/active_metadata.py

@@ -0,0 +1,160 @@
+"""Persistence models for active metadata discovery and field lineage."""
+
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+
+from app import db
+from app.core.common.identifiers import new_governance_uid
+
+
+class ActiveMetadataPlan(db.Model):
+    __tablename__ = "active_metadata_plans"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    source_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    name = db.Column(db.String(300), nullable=False)
+    source_kind = db.Column(db.String(20), nullable=False)
+    schedule_type = db.Column(db.String(20), nullable=False)
+    schedule_expression = db.Column(db.String(120))
+    discovery_mode = db.Column(db.String(20), nullable=False)
+    scope = db.Column(JSONB, nullable=False)
+    cursor_state = db.Column(JSONB, nullable=False)
+    owner_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    enabled = db.Column(db.Boolean, nullable=False)
+    current_version = db.Column(db.Integer, nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class ActiveMetadataRun(db.Model):
+    __tablename__ = "active_metadata_runs"
+    __table_args__ = (
+        db.UniqueConstraint("plan_uid", "batch_key"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    plan_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    batch_key = db.Column(db.String(160), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    attempt_count = db.Column(db.Integer, nullable=False)
+    cursor_before = db.Column(JSONB, nullable=False)
+    cursor_after = db.Column(JSONB, nullable=False)
+    snapshot_hash = db.Column(db.String(64))
+    statistics = db.Column(JSONB, nullable=False)
+    failure_code = db.Column(db.String(80))
+    failure_reason = db.Column(db.String(500))
+    actor_uid = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class ActiveMetadataAsset(db.Model):
+    __tablename__ = "active_metadata_assets"
+    __table_args__ = (
+        db.UniqueConstraint("source_uid", "asset_key"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True)
+    source_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    asset_key = db.Column(db.String(500), nullable=False)
+    namespace = db.Column(db.String(200), nullable=False)
+    name = db.Column(db.String(200), nullable=False)
+    asset_type = db.Column(db.String(40), nullable=False)
+    lifecycle_status = db.Column(db.String(30), nullable=False)
+    current_version = db.Column(db.Integer, nullable=False)
+    content_hash = db.Column(db.String(64), nullable=False)
+    snapshot = db.Column(JSONB, nullable=False)
+    health = db.Column(JSONB, nullable=False)
+    last_run_uid = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class ActiveMetadataAssetVersion(db.Model):
+    __tablename__ = "active_metadata_asset_versions"
+    __table_args__ = (
+        db.UniqueConstraint("asset_uid", "version"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    version = db.Column(db.Integer, nullable=False)
+    run_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    content_hash = db.Column(db.String(64), nullable=False)
+    snapshot = db.Column(JSONB, nullable=False)
+    actor_uid = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class ActiveMetadataChange(db.Model):
+    __tablename__ = "active_metadata_changes"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    run_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    asset_key = db.Column(db.String(500), nullable=False)
+    field_name = db.Column(db.String(200))
+    change_type = db.Column(db.String(40), nullable=False)
+    before_state = db.Column(JSONB)
+    after_state = db.Column(JSONB)
+    status = db.Column(db.String(20), nullable=False)
+
+
+class ActiveMetadataLineage(db.Model):
+    __tablename__ = "active_metadata_lineage"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    run_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    parse_status = db.Column(db.String(20), nullable=False)
+    source_asset = db.Column(db.String(500))
+    source_field = db.Column(db.String(200))
+    target_asset = db.Column(db.String(500))
+    target_field = db.Column(db.String(200))
+    relation_type = db.Column(db.String(40), nullable=False)
+    evidence = db.Column(JSONB, nullable=False)
+    failure_reason = db.Column(db.String(500))
+
+
+class ActiveMetadataHealthSignal(db.Model):
+    __tablename__ = "active_metadata_health_signals"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    run_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    signal_type = db.Column(db.String(30), nullable=False)
+    value = db.Column(JSONB)
+    status = db.Column(db.String(20), nullable=False)
+    evidence = db.Column(JSONB, nullable=False)
+
+
+class ActiveMetadataCorrection(db.Model):
+    __tablename__ = "active_metadata_corrections"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    field_name = db.Column(db.String(200))
+    proposed_value = db.Column(JSONB, nullable=False)
+    reason = db.Column(db.String(500), nullable=False)
+    assignee_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    resolution = db.Column(JSONB, nullable=False)
+    current_version = db.Column(db.Integer, nullable=False)
+    submitted_by = db.Column(UUID(as_uuid=False), nullable=False)
+    resolved_by = db.Column(UUID(as_uuid=False))
+
+
+class ActiveMetadataCorrectionAudit(db.Model):
+    __tablename__ = "active_metadata_correction_audits"
+    __table_args__ = (
+        db.UniqueConstraint("correction_uid", "version"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    correction_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    version = db.Column(db.Integer, nullable=False)
+    action = db.Column(db.String(40), nullable=False)
+    before_state = db.Column(JSONB)
+    after_state = db.Column(JSONB, nullable=False)
+    actor_uid = db.Column(UUID(as_uuid=False), nullable=False)

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

@@ -65,6 +65,10 @@ def get_catalog_ingestion_executor():
     from app.core.data_research.catalog.service import CatalogCollectionService
     from app.core.data_research.errors import IngestionSourceInvalid
     from app.core.data_source.runtime import get_data_source_manager
+    from app.core.meta_data.active_metadata import ActiveMetadataService
+    from app.core.meta_data.active_metadata_repository import (
+        SqlAlchemyActiveMetadataRepository,
+    )
 
     manager = get_data_source_manager()
 
@@ -82,12 +86,29 @@ def get_catalog_ingestion_executor():
         definition_resolver=manager.definitions.get,
         collector_resolver=collector_resolver,
     )
+    active_metadata = ActiveMetadataService(
+        SqlAlchemyActiveMetadataRepository(db.session)
+    )
+
+    def project_active_metadata(job, snapshot_record):
+        active_metadata.execute_source_plans(
+            snapshot_record.source_uid,
+            snapshot_record.snapshot,
+            batch_key=f"catalog:{snapshot_record.uid}",
+            actor_uid=job.actor_uid,
+            cursor_after={
+                "catalog_snapshot_uid": snapshot_record.uid,
+                "content_hash": snapshot_record.content_hash,
+            },
+        )
+
     return CatalogIngestionExecutor(
         get_ingestion_service(),
         collector,
         get_catalog_snapshot_repository(),
         commit=db.session.commit,
         rollback=db.session.rollback,
+        on_snapshot=project_active_metadata,
     )
 
 

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

@@ -5,6 +5,7 @@ from flask import Blueprint
 bp = Blueprint("meta_data", __name__)
 
 from app.api.meta_data import (
+    active_metadata,
     domain_templates,
     routes,
 )

+ 154 - 0
deployment/app/api/meta_data/active_metadata.py

@@ -0,0 +1,154 @@
+"""HTTP API for active metadata discovery and field lineage."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.meta_data import bp
+from app.core.meta_data.active_metadata import (
+    ActiveMetadataConflict,
+    ActiveMetadataError,
+    ActiveMetadataNotFound,
+    ActiveMetadataService,
+)
+from app.core.meta_data.active_metadata_repository import (
+    SqlAlchemyActiveMetadataRepository,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    return ActiveMetadataService(SqlAlchemyActiveMetadataRepository(db.session))
+
+
+def _error(exc):
+    db.session.rollback()
+    if isinstance(exc, ActiveMetadataNotFound):
+        return jsonify(failed(str(exc), code=404)), 404
+    if isinstance(exc, ActiveMetadataConflict):
+        return jsonify(failed(str(exc), code=409)), 409
+    if isinstance(exc, ActiveMetadataError):
+        return jsonify(failed(str(exc), code=400)), 400
+    raise exc
+
+
+@bp.route("/active-metadata/plans", methods=["GET"])
+def list_active_metadata_plans():
+    return jsonify(success(_service().list_plans()))
+
+
+@bp.route("/active-metadata/plans", methods=["POST"])
+def create_active_metadata_plan():
+    try:
+        result = _service().create_plan(
+            request.get_json(silent=True),
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "主动发现计划已创建")), 201
+    except Exception as exc:
+        return _error(exc)
+
+@bp.route("/active-metadata/plans/<plan_uid>/runs", methods=["POST"])
+def execute_active_metadata_plan(plan_uid):
+    batch_key = str(request.headers.get("Idempotency-Key") or "").strip()
+    if not batch_key:
+        return jsonify(failed("Idempotency-Key is required", code=428)), 428
+    try:
+        result = _service().execute(
+            plan_uid,
+            request.get_json(silent=True),
+            batch_key=batch_key,
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "主动发现批次已完成"))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/plans/<plan_uid>/runs", methods=["GET"])
+def list_active_metadata_runs(plan_uid):
+    try:
+        return jsonify(success(_service().list_runs(plan_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/assets", methods=["GET"])
+def list_active_metadata_assets():
+    try:
+        return jsonify(
+            success(_service().list_assets(request.args.get("source_uid")))
+        )
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/runs/<run_uid>/changes", methods=["GET"])
+def list_active_metadata_changes(run_uid):
+    try:
+        return jsonify(success(_service().list_changes(run_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/runs/<run_uid>/lineage", methods=["GET"])
+def list_active_metadata_lineage(run_uid):
+    try:
+        return jsonify(success(_service().list_lineage(run_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/runs/<run_uid>/health-signals", methods=["GET"])
+def list_active_metadata_health(run_uid):
+    try:
+        return jsonify(success(_service().list_health_signals(run_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/corrections", methods=["GET"])
+def list_active_metadata_corrections():
+    try:
+        return jsonify(
+            success(_service().list_corrections(request.args.get("asset_uid")))
+        )
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/assets/<asset_uid>/corrections", methods=["POST"])
+def submit_active_metadata_correction(asset_uid):
+    try:
+        result = _service().submit_correction(
+            asset_uid,
+            request.get_json(silent=True),
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "元数据纠错已提交")), 201
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route(
+    "/active-metadata/corrections/<correction_uid>/resolve",
+    methods=["POST"],
+)
+def resolve_active_metadata_correction(correction_uid):
+    body = request.get_json(silent=True) or {}
+    try:
+        result = _service().resolve_correction(
+            correction_uid,
+            expected_version=body.get("expected_version"),
+            decision=body.get("decision"),
+            resolution=body.get("resolution") or {},
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "元数据纠错已处置"))
+    except Exception as exc:
+        return _error(exc)

+ 4 - 2
deployment/app/core/data_research/catalog/execution.py

@@ -1,11 +1,10 @@
 from __future__ import annotations
 
+from app.core.data_research.catalog.models import CatalogScope
 from app.core.data_research.errors import (
     IngestionPayloadInvalid,
     InvalidJobTransition,
 )
-from app.core.data_research.catalog.models import CatalogScope
-
 
 _SCOPE_FIELDS = (
     "include_schemas",
@@ -41,12 +40,14 @@ class CatalogIngestionExecutor:
         *,
         commit=lambda: None,
         rollback=lambda: None,
+        on_snapshot=lambda _job, _snapshot: None,
     ):
         self.ingestion = ingestion
         self.collector = collector
         self.snapshots = snapshots
         self.commit = commit
         self.rollback = rollback
+        self.on_snapshot = on_snapshot
 
     @staticmethod
     def _report(snapshot_record):
@@ -95,6 +96,7 @@ class CatalogIngestionExecutor:
                     job.attempt_count,
                     snapshot,
                 )
+                self.on_snapshot(job, snapshot_record)
                 self.commit()
             if job.status == "normalizing":
                 job = self.ingestion.transition(job.uid, "matching")

+ 703 - 0
deployment/app/core/meta_data/active_metadata.py

@@ -0,0 +1,703 @@
+"""Active metadata discovery, incremental change, lineage and correction contracts."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+import uuid
+from collections import Counter
+from copy import deepcopy
+from datetime import UTC, datetime
+from typing import Any
+
+import sqlglot
+from sqlglot import exp
+
+from app.core.common.identifiers import new_governance_uid
+
+SOURCE_KINDS = frozenset({"database", "file", "api"})
+SCHEDULE_TYPES = frozenset({"manual", "interval", "cron"})
+DISCOVERY_MODES = frozenset({"snapshot", "cursor"})
+HEALTH_SIGNAL_TYPES = frozenset({"quality", "freshness", "task_failure", "usage"})
+HEALTH_STATUSES = frozenset({"healthy", "warning", "critical", "unknown"})
+SECRET_MARKERS = ("password", "secret", "token", "credential", "api_key", "private_key")
+INTERVAL_RE = re.compile(r"^PT(?:[1-9]\d*[HMS])+$")
+CRON_PART_RE = re.compile(r"^[\d*/?,LW#-]+$")
+
+
+class ActiveMetadataError(ValueError):
+    pass
+
+
+class ActiveMetadataNotFound(LookupError):
+    pass
+
+
+class ActiveMetadataConflict(RuntimeError):
+    pass
+
+
+def _canonical(value: Any) -> str:
+    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+
+
+def _hash(value: Any) -> str:
+    return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
+
+
+def _reject_secrets(value: Any, path: str = "payload") -> None:
+    if isinstance(value, dict):
+        for key, nested in value.items():
+            normalized = str(key).lower().replace("-", "_")
+            if any(marker in normalized for marker in SECRET_MARKERS):
+                raise ActiveMetadataError(f"secret-bearing field is not allowed at {path}.{key}")
+            _reject_secrets(nested, f"{path}.{key}")
+    elif isinstance(value, list):
+        for index, nested in enumerate(value):
+            _reject_secrets(nested, f"{path}[{index}]")
+
+
+def _text(value: Any, field: str, limit: int = 300) -> str:
+    result = str(value or "").strip()
+    if not result or len(result) > limit:
+        raise ActiveMetadataError(f"{field} must be between 1 and {limit} characters")
+    return result
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (TypeError, ValueError, AttributeError) as exc:
+        raise ActiveMetadataError(f"{field} must be a UUID") from exc
+
+
+def _schedule(payload: dict[str, Any]) -> tuple[str, str | None]:
+    schedule_type = str(payload.get("schedule_type") or "manual").strip().lower()
+    if schedule_type not in SCHEDULE_TYPES:
+        raise ActiveMetadataError("schedule_type is unsupported")
+    expression = payload.get("schedule_expression")
+    expression = str(expression).strip() if expression is not None else None
+    if schedule_type == "manual":
+        if expression:
+            raise ActiveMetadataError("manual schedule cannot have schedule_expression")
+        return schedule_type, None
+    if schedule_type == "interval":
+        if not expression or not INTERVAL_RE.fullmatch(expression):
+            raise ActiveMetadataError("interval schedule_expression must be ISO-8601 PT duration")
+        return schedule_type, expression
+    parts = expression.split() if expression else []
+    if len(parts) != 5 or any(not CRON_PART_RE.fullmatch(part) for part in parts):
+        raise ActiveMetadataError("cron schedule_expression must contain five valid fields")
+    return schedule_type, expression
+
+
+def _table_name(table: exp.Table) -> str:
+    return ".".join(part for part in (table.catalog, table.db, table.name) if part)
+
+
+def parse_sql_field_lineage(sql: str, *, dialect: str | None = None) -> dict[str, Any]:
+    """Parse conservative field lineage and preserve unsupported SQL as evidence."""
+    evidence = str(sql or "").strip()
+    if not evidence:
+        return {"status": "failed", "edges": [], "failure_reason": "SQL is empty"}
+    try:
+        statement = sqlglot.parse_one(evidence, read=dialect)
+        if not isinstance(statement, exp.Insert):
+            raise ActiveMetadataError("only INSERT ... SELECT lineage is supported")
+        target_schema = statement.this
+        if not isinstance(target_schema, exp.Schema) or not isinstance(
+            target_schema.this, exp.Table
+        ):
+            raise ActiveMetadataError("INSERT target columns are required")
+        select = statement.expression
+        if not isinstance(select, exp.Select):
+            raise ActiveMetadataError("INSERT source must be a SELECT")
+        targets = [item.name for item in target_schema.expressions]
+        if len(targets) != len(select.expressions):
+            raise ActiveMetadataError("target and select field counts differ")
+        tables = list(select.find_all(exp.Table))
+        aliases = {}
+        for table in tables:
+            name = _table_name(table)
+            aliases[table.alias_or_name] = name
+            aliases[table.name] = name
+        edges = []
+        for target_field, expression in zip(targets, select.expressions, strict=True):
+            columns = list(expression.find_all(exp.Column))
+            if not columns and isinstance(expression, exp.Column):
+                columns = [expression]
+            for column in columns:
+                if column.table:
+                    source_asset = aliases.get(column.table)
+                else:
+                    source_asset = _table_name(tables[0]) if len(tables) == 1 else None
+                if not source_asset:
+                    raise ActiveMetadataError(
+                        f"cannot resolve source table for field {column.name}"
+                    )
+                edges.append(
+                    {
+                        "source_asset": source_asset,
+                        "source_field": column.name,
+                        "target_asset": _table_name(target_schema.this),
+                        "target_field": target_field,
+                        "relation_type": "derived_from",
+                        "evidence": {"kind": "sql", "statement_hash": _hash(evidence)},
+                    }
+                )
+        if not edges:
+            raise ActiveMetadataError("no field lineage was resolved")
+        return {"status": "resolved", "edges": edges, "failure_reason": None}
+    except Exception as exc:
+        return {
+            "status": "failed",
+            "edges": [],
+            "failure_reason": str(exc)[:500],
+        }
+
+
+def _normalize_fields(asset_key: str, fields: Any) -> list[dict[str, Any]]:
+    if fields is None:
+        fields = []
+    if not isinstance(fields, list) or len(fields) > 5000:
+        raise ActiveMetadataError(f"{asset_key}.fields must be a bounded list")
+    result = []
+    seen = set()
+    for index, field in enumerate(fields, 1):
+        if not isinstance(field, dict):
+            raise ActiveMetadataError(f"{asset_key}.fields items must be objects")
+        name = _text(field.get("name"), f"{asset_key}.field.name", 200)
+        if name in seen:
+            raise ActiveMetadataError(f"duplicate field {asset_key}.{name}")
+        seen.add(name)
+        result.append(
+            {
+                "name": name,
+                "data_type": _text(
+                    field.get("data_type") or "unknown",
+                    f"{asset_key}.{name}.data_type",
+                    100,
+                ),
+                "nullable": bool(field.get("nullable", True)),
+                "ordinal_position": int(field.get("ordinal_position") or index),
+                "default": field.get("default"),
+                "comment": field.get("comment"),
+            }
+        )
+    return sorted(result, key=lambda item: (item["ordinal_position"], item["name"]))
+
+
+def normalize_snapshot(source_uid: str, snapshot: Any) -> list[dict[str, Any]]:
+    if not isinstance(snapshot, dict) or not isinstance(snapshot.get("assets"), list):
+        raise ActiveMetadataError("snapshot.assets must be a list")
+    if len(snapshot["assets"]) > 10000:
+        raise ActiveMetadataError("snapshot contains too many assets")
+    result = []
+    seen = set()
+    for item in snapshot["assets"]:
+        if not isinstance(item, dict):
+            raise ActiveMetadataError("snapshot asset must be an object")
+        namespace = _text(
+            item.get("namespace") or item.get("schema") or "default",
+            "asset.namespace",
+            200,
+        )
+        name = _text(item.get("name"), "asset.name", 200)
+        key = str(item.get("key") or f"{source_uid}:{namespace}.{name}").strip()
+        if not key.startswith(f"{source_uid}:"):
+            raise ActiveMetadataError("asset key must be scoped to source_uid")
+        if key in seen:
+            raise ActiveMetadataError(f"duplicate asset key {key}")
+        seen.add(key)
+        normalized = {
+            "key": key,
+            "namespace": namespace,
+            "name": name,
+            "asset_type": _text(
+                item.get("asset_type") or "resource", "asset.asset_type", 40
+            ),
+            "comment": item.get("comment"),
+            "fields": _normalize_fields(key, item.get("fields")),
+        }
+        normalized["content_hash"] = _hash(normalized)
+        result.append(normalized)
+    return sorted(result, key=lambda item: item["key"])
+
+
+def _stable_asset_uid(source_uid: str, asset_key: str) -> str:
+    return str(
+        uuid.uuid5(
+            uuid.NAMESPACE_URL,
+            f"dataops-active-metadata:{source_uid}:{asset_key}",
+        )
+    )
+
+
+class ActiveMetadataService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory=new_governance_uid,
+        now_factory=lambda: datetime.now(UTC),
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+
+    def create_plan(self, payload: Any, *, actor_uid: str):
+        if not isinstance(payload, dict):
+            raise ActiveMetadataError("plan payload must be an object")
+        _reject_secrets(payload)
+        source_kind = str(payload.get("source_kind") or "").strip().lower()
+        if source_kind not in SOURCE_KINDS:
+            raise ActiveMetadataError("source_kind is unsupported")
+        discovery_mode = str(payload.get("discovery_mode") or "snapshot").strip().lower()
+        if discovery_mode not in DISCOVERY_MODES:
+            raise ActiveMetadataError("discovery_mode is unsupported")
+        schedule_type, schedule_expression = _schedule(payload)
+        scope = deepcopy(payload.get("scope") or {})
+        if not isinstance(scope, dict):
+            raise ActiveMetadataError("scope must be an object")
+        plan = {
+            "uid": self.uid_factory(),
+            "source_uid": _uuid(payload.get("source_uid"), "source_uid"),
+            "name": _text(payload.get("name"), "name"),
+            "source_kind": source_kind,
+            "schedule_type": schedule_type,
+            "schedule_expression": schedule_expression,
+            "discovery_mode": discovery_mode,
+            "scope": scope,
+            "cursor_state": {},
+            "owner_uid": _uuid(payload.get("owner_uid"), "owner_uid"),
+            "enabled": bool(payload.get("enabled", True)),
+            "current_version": 1,
+            "created_by": _uuid(actor_uid, "actor_uid"),
+            "created_at": self.now_factory().isoformat(),
+        }
+        return self.repository.save_plan(plan)
+
+    def list_plans(self):
+        return self.repository.list_plans()
+
+    def execute_source_plans(
+        self,
+        source_uid: str,
+        snapshot: dict[str, Any],
+        *,
+        batch_key: str,
+        actor_uid: str,
+        cursor_after: dict[str, Any] | None = None,
+    ):
+        source_uid = _uuid(source_uid, "source_uid")
+        results = []
+        for plan in self.list_plans():
+            if (
+                plan["source_uid"] != source_uid
+                or plan["source_kind"] != "database"
+                or not plan["enabled"]
+            ):
+                continue
+            results.append(
+                self.execute(
+                    plan["uid"],
+                    {
+                        "snapshot": deepcopy(snapshot),
+                        "cursor_after": deepcopy(cursor_after or {}),
+                    },
+                    batch_key=batch_key,
+                    actor_uid=actor_uid,
+                )
+            )
+        return results
+
+    def get_plan(self, uid: str):
+        result = self.repository.get_plan(_uuid(uid, "plan_uid"))
+        if result is None:
+            raise ActiveMetadataNotFound("active metadata plan was not found")
+        return result
+
+    def list_assets(self, source_uid: str):
+        return self.repository.list_assets(_uuid(source_uid, "source_uid"))
+
+    def list_runs(self, plan_uid: str):
+        self.get_plan(plan_uid)
+        return self.repository.list_runs(plan_uid)
+
+    def list_changes(self, run_uid: str):
+        return self.repository.list_changes(_uuid(run_uid, "run_uid"))
+
+    def list_lineage(self, run_uid: str):
+        return self.repository.list_lineage(_uuid(run_uid, "run_uid"))
+
+    def list_health_signals(self, run_uid: str):
+        return self.repository.list_health_signals(_uuid(run_uid, "run_uid"))
+
+    def list_corrections(self, asset_uid: str | None = None):
+        return self.repository.list_corrections(
+            _uuid(asset_uid, "asset_uid") if asset_uid else None
+        )
+
+    def execute(
+        self,
+        plan_uid: str,
+        payload: Any,
+        *,
+        batch_key: str,
+        actor_uid: str,
+    ):
+        plan = self.get_plan(plan_uid)
+        batch_key = _text(batch_key, "batch_key", 160)
+        existing = self.repository.find_run_by_batch(plan["uid"], batch_key)
+        if existing is not None:
+            return existing
+        if not isinstance(payload, dict):
+            raise ActiveMetadataError("run payload must be an object")
+        _reject_secrets(payload)
+        current_assets = normalize_snapshot(plan["source_uid"], payload.get("snapshot"))
+        previous = {item["asset_key"]: item for item in self.repository.list_assets(plan["source_uid"])}
+        current = {item["key"]: item for item in current_assets}
+        run_uid = self.uid_factory()
+        now = self.now_factory().isoformat()
+        assets = []
+        versions = []
+        changes = []
+
+        for key, snapshot in current.items():
+            prior = previous.get(key)
+            asset_uid = prior["uid"] if prior else _stable_asset_uid(plan["source_uid"], key)
+            version = int(prior["current_version"]) if prior else 0
+            changed = prior is None or prior["content_hash"] != snapshot["content_hash"]
+            if changed:
+                version += 1
+                versions.append(
+                    {
+                        "uid": self.uid_factory(),
+                        "asset_uid": asset_uid,
+                        "version": version,
+                        "run_uid": run_uid,
+                        "content_hash": snapshot["content_hash"],
+                        "snapshot": deepcopy(snapshot),
+                        "actor_uid": _uuid(actor_uid, "actor_uid"),
+                        "created_at": now,
+                    }
+                )
+            asset = {
+                "uid": asset_uid,
+                "source_uid": plan["source_uid"],
+                "asset_key": key,
+                "namespace": snapshot["namespace"],
+                "name": snapshot["name"],
+                "asset_type": snapshot["asset_type"],
+                "lifecycle_status": "active",
+                "current_version": max(version, 1),
+                "content_hash": snapshot["content_hash"],
+                "snapshot": deepcopy(snapshot),
+                "health": deepcopy(prior.get("health", {}) if prior else {}),
+                "last_run_uid": run_uid,
+                "updated_at": now,
+            }
+            assets.append(asset)
+            if prior is None:
+                changes.append(
+                    self._change(run_uid, asset, "asset_added", None, None, snapshot)
+                )
+            elif changed:
+                changes.extend(self._field_changes(run_uid, asset, prior["snapshot"], snapshot))
+
+        for key in sorted(set(previous) - set(current)):
+            prior = deepcopy(previous[key])
+            prior["lifecycle_status"] = "deletion_candidate"
+            prior["last_run_uid"] = run_uid
+            prior["updated_at"] = now
+            assets.append(prior)
+            changes.append(
+                self._change(
+                    run_uid,
+                    prior,
+                    "deletion_candidate",
+                    None,
+                    prior["snapshot"],
+                    None,
+                )
+            )
+
+        health_signals = self._health_signals(
+            payload.get("health_signals") or [],
+            assets,
+            run_uid,
+            now,
+        )
+        lineage = self._lineage(payload.get("lineage_sql") or [], run_uid, now)
+        counts = Counter(item["change_type"] for item in changes)
+        run = {
+            "uid": run_uid,
+            "plan_uid": plan["uid"],
+            "batch_key": batch_key,
+            "status": "completed",
+            "attempt_count": 1,
+            "cursor_before": deepcopy(plan.get("cursor_state") or {}),
+            "cursor_after": deepcopy(payload.get("cursor_after") or {}),
+            "snapshot_hash": _hash(current_assets),
+            "statistics": dict(counts),
+            "failure_code": None,
+            "failure_reason": None,
+            "actor_uid": _uuid(actor_uid, "actor_uid"),
+            "started_at": now,
+            "finished_at": now,
+        }
+        return self.repository.apply_discovery(
+            {
+                "run": run,
+                "assets": assets,
+                "versions": versions,
+                "changes": changes,
+                "lineage": lineage,
+                "health_signals": health_signals,
+            }
+        )
+
+    def _change(self, run_uid, asset, change_type, field_name, before, after):
+        return {
+            "uid": self.uid_factory(),
+            "run_uid": run_uid,
+            "asset_uid": asset["uid"],
+            "asset_key": asset["asset_key"],
+            "field_name": field_name,
+            "change_type": change_type,
+            "before_state": deepcopy(before),
+            "after_state": deepcopy(after),
+            "status": "pending",
+        }
+
+    def _field_changes(self, run_uid, asset, before, after):
+        before_fields = {item["name"]: item for item in before.get("fields", [])}
+        after_fields = {item["name"]: item for item in after.get("fields", [])}
+        changes = []
+        for name in sorted(set(after_fields) - set(before_fields)):
+            changes.append(
+                self._change(run_uid, asset, "field_added", name, None, after_fields[name])
+            )
+        for name in sorted(set(before_fields) - set(after_fields)):
+            changes.append(
+                self._change(
+                    run_uid,
+                    asset,
+                    "field_deletion_candidate",
+                    name,
+                    before_fields[name],
+                    None,
+                )
+            )
+        for name in sorted(set(before_fields) & set(after_fields)):
+            if before_fields[name] != after_fields[name]:
+                changes.append(
+                    self._change(
+                        run_uid,
+                        asset,
+                        "field_changed",
+                        name,
+                        before_fields[name],
+                        after_fields[name],
+                    )
+                )
+        return changes
+
+    def _health_signals(self, raw_signals, assets, run_uid, now):
+        if not isinstance(raw_signals, list):
+            raise ActiveMetadataError("health_signals must be a list")
+        by_key = {item["asset_key"]: item for item in assets}
+        result = []
+        for raw in raw_signals:
+            if not isinstance(raw, dict):
+                raise ActiveMetadataError("health signal must be an object")
+            asset = by_key.get(str(raw.get("asset_key") or ""))
+            if asset is None:
+                raise ActiveMetadataError("health signal asset_key is unknown")
+            signal_type = str(raw.get("signal_type") or "").lower()
+            status = str(raw.get("status") or "unknown").lower()
+            if signal_type not in HEALTH_SIGNAL_TYPES or status not in HEALTH_STATUSES:
+                raise ActiveMetadataError("health signal type or status is unsupported")
+            signal = {
+                "uid": self.uid_factory(),
+                "asset_uid": asset["uid"],
+                "run_uid": run_uid,
+                "signal_type": signal_type,
+                "value": raw.get("value"),
+                "status": status,
+                "evidence": deepcopy(raw.get("evidence") or {}),
+                "observed_at": str(raw.get("observed_at") or now),
+            }
+            asset["health"][signal_type] = {
+                "value": signal["value"],
+                "status": status,
+                "observed_at": signal["observed_at"],
+            }
+            result.append(signal)
+        return result
+
+    def _lineage(self, statements, run_uid, now):
+        if not isinstance(statements, list):
+            raise ActiveMetadataError("lineage_sql must be a list")
+        records = []
+        for item in statements:
+            if isinstance(item, str):
+                sql, dialect = item, None
+            elif isinstance(item, dict):
+                sql, dialect = item.get("sql"), item.get("dialect")
+            else:
+                raise ActiveMetadataError("lineage_sql item must be text or object")
+            parsed = parse_sql_field_lineage(sql, dialect=dialect)
+            if parsed["status"] == "failed":
+                records.append(
+                    {
+                        "uid": self.uid_factory(),
+                        "run_uid": run_uid,
+                        "parse_status": "failed",
+                        "source_asset": None,
+                        "source_field": None,
+                        "target_asset": None,
+                        "target_field": None,
+                        "relation_type": "derived_from",
+                        "evidence": {"statement_hash": _hash(str(sql or ""))},
+                        "failure_reason": parsed["failure_reason"],
+                        "created_at": now,
+                    }
+                )
+            for edge in parsed["edges"]:
+                records.append(
+                    {
+                        "uid": self.uid_factory(),
+                        "run_uid": run_uid,
+                        "parse_status": "resolved",
+                        **edge,
+                        "failure_reason": None,
+                        "created_at": now,
+                    }
+                )
+        return records
+
+    def record_failure(
+        self,
+        plan_uid: str,
+        *,
+        batch_key: str,
+        error_code: str,
+        failure_reason: str,
+        actor_uid: str,
+    ):
+        plan = self.get_plan(plan_uid)
+        existing = self.repository.find_run_by_batch(plan["uid"], batch_key)
+        if existing is not None:
+            return existing
+        now = self.now_factory().isoformat()
+        run_uid = self.uid_factory()
+        run = {
+            "uid": run_uid,
+            "plan_uid": plan["uid"],
+            "batch_key": _text(batch_key, "batch_key", 160),
+            "status": "failed",
+            "attempt_count": 1,
+            "cursor_before": deepcopy(plan.get("cursor_state") or {}),
+            "cursor_after": deepcopy(plan.get("cursor_state") or {}),
+            "snapshot_hash": None,
+            "statistics": {},
+            "failure_code": _text(error_code, "error_code", 80),
+            "failure_reason": _text(failure_reason, "failure_reason", 500),
+            "actor_uid": _uuid(actor_uid, "actor_uid"),
+            "started_at": now,
+            "finished_at": now,
+        }
+        signals = [
+            {
+                "uid": self.uid_factory(),
+                "asset_uid": asset["uid"],
+                "run_uid": run_uid,
+                "signal_type": "task_failure",
+                "value": 1,
+                "status": "critical",
+                "evidence": {"error_code": run["failure_code"]},
+                "observed_at": now,
+            }
+            for asset in self.repository.list_assets(plan["source_uid"])
+        ]
+        return self.repository.record_failed_run(run, signals)
+
+    def submit_correction(self, asset_uid: str, payload: Any, *, actor_uid: str):
+        asset = self.repository.get_asset(_uuid(asset_uid, "asset_uid"))
+        if asset is None:
+            raise ActiveMetadataNotFound("active metadata asset was not found")
+        if not isinstance(payload, dict):
+            raise ActiveMetadataError("correction payload must be an object")
+        _reject_secrets(payload)
+        now = self.now_factory().isoformat()
+        correction = {
+            "uid": self.uid_factory(),
+            "asset_uid": asset["uid"],
+            "field_name": str(payload.get("field_name") or "").strip() or None,
+            "proposed_value": deepcopy(payload.get("proposed_value") or {}),
+            "reason": _text(payload.get("reason"), "reason", 500),
+            "assignee_uid": _uuid(payload.get("assignee_uid"), "assignee_uid"),
+            "status": "pending",
+            "resolution": {},
+            "current_version": 1,
+            "submitted_by": _uuid(actor_uid, "actor_uid"),
+            "created_at": now,
+            "updated_at": now,
+        }
+        return self.repository.save_correction(
+            correction,
+            self._correction_audit(correction, "correction_submitted", actor_uid, None),
+        )
+
+    def resolve_correction(
+        self,
+        correction_uid: str,
+        *,
+        expected_version: int,
+        decision: str,
+        resolution: dict[str, Any],
+        actor_uid: str,
+    ):
+        correction = self.repository.get_correction(_uuid(correction_uid, "correction_uid"))
+        if correction is None:
+            raise ActiveMetadataNotFound("active metadata correction was not found")
+        if int(correction["current_version"]) != int(expected_version):
+            raise ActiveMetadataConflict("correction version conflict")
+        actor_uid = _uuid(actor_uid, "actor_uid")
+        if actor_uid != correction["assignee_uid"]:
+            raise ActiveMetadataError("only the correction assignee may resolve it")
+        if decision not in {"accept", "reject"}:
+            raise ActiveMetadataError("decision must be accept or reject")
+        before = deepcopy(correction)
+        correction.update(
+            {
+                "status": "resolved" if decision == "accept" else "rejected",
+                "resolution": deepcopy(resolution or {}),
+                "current_version": int(correction["current_version"]) + 1,
+                "updated_at": self.now_factory().isoformat(),
+                "resolved_by": actor_uid,
+            }
+        )
+        return self.repository.resolve_correction(
+            correction,
+            self._correction_audit(
+                correction,
+                "correction_resolved",
+                actor_uid,
+                before,
+            ),
+        )
+
+    def _correction_audit(self, correction, action, actor_uid, before):
+        return {
+            "uid": self.uid_factory(),
+            "correction_uid": correction["uid"],
+            "version": correction["current_version"],
+            "action": action,
+            "before_state": deepcopy(before),
+            "after_state": deepcopy(correction),
+            "actor_uid": _uuid(actor_uid, "actor_uid"),
+            "created_at": self.now_factory().isoformat(),
+        }

+ 500 - 0
deployment/app/core/meta_data/active_metadata_repository.py

@@ -0,0 +1,500 @@
+"""PostgreSQL repository for active metadata discovery."""
+
+from __future__ import annotations
+
+import json
+import uuid
+
+from sqlalchemy import text
+
+
+def _json(value):
+    return json.dumps(value, ensure_ascii=False, sort_keys=True)
+
+
+def _row(row):
+    if row is None:
+        return None
+    result = dict(row)
+    for key, value in tuple(result.items()):
+        if isinstance(value, uuid.UUID):
+            result[key] = str(value)
+        elif hasattr(value, "isoformat"):
+            result[key] = value.isoformat()
+        elif isinstance(value, dict):
+            result[key] = dict(value)
+    return result
+
+
+class SqlAlchemyActiveMetadataRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def save_plan(self, plan):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.active_metadata_plans (
+                    uid, source_uid, name, source_kind, schedule_type,
+                    schedule_expression, discovery_mode, scope, cursor_state,
+                    owner_uid, enabled, current_version, created_by, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:source_uid AS uuid), :name,
+                    :source_kind, :schedule_type, :schedule_expression,
+                    :discovery_mode, CAST(:scope AS jsonb),
+                    CAST(:cursor_state AS jsonb), CAST(:owner_uid AS uuid),
+                    :enabled, :current_version, CAST(:created_by AS uuid),
+                    CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **plan,
+                "scope": _json(plan["scope"]),
+                "cursor_state": _json(plan["cursor_state"]),
+            },
+        )
+        return self.get_plan(plan["uid"])
+
+    def get_plan(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, source_uid, name, source_kind, schedule_type,
+                           schedule_expression, discovery_mode, scope,
+                           cursor_state, owner_uid, enabled, current_version,
+                           created_by, created_at, updated_at
+                    FROM public.active_metadata_plans
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def list_plans(self):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, source_uid, name, source_kind, schedule_type,
+                           schedule_expression, discovery_mode, scope,
+                           cursor_state, owner_uid, enabled, current_version,
+                           created_by, created_at, updated_at
+                    FROM public.active_metadata_plans
+                    ORDER BY created_at DESC, uid DESC
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        ]
+
+    def find_run_by_batch(self, plan_uid, batch_key):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, plan_uid, batch_key, status, attempt_count,
+                           cursor_before, cursor_after, snapshot_hash,
+                           statistics, failure_code, failure_reason, actor_uid,
+                           started_at, finished_at, created_at
+                    FROM public.active_metadata_runs
+                    WHERE plan_uid = CAST(:plan_uid AS uuid)
+                      AND batch_key = :batch_key
+                    """
+                ),
+                {"plan_uid": plan_uid, "batch_key": batch_key},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def list_assets(self, source_uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, source_uid, asset_key, namespace, name,
+                           asset_type, lifecycle_status, current_version,
+                           content_hash, snapshot, health, last_run_uid,
+                           created_at, updated_at
+                    FROM public.active_metadata_assets
+                    WHERE source_uid = CAST(:source_uid AS uuid)
+                    ORDER BY namespace, name
+                    """
+                ),
+                {"source_uid": source_uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def get_asset(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, source_uid, asset_key, namespace, name,
+                           asset_type, lifecycle_status, current_version,
+                           content_hash, snapshot, health, last_run_uid,
+                           created_at, updated_at
+                    FROM public.active_metadata_assets
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def apply_discovery(self, operation):
+        run = operation["run"]
+        self._insert_run(run)
+        for asset in operation["assets"]:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_assets (
+                        uid, source_uid, asset_key, namespace, name,
+                        asset_type, lifecycle_status, current_version,
+                        content_hash, snapshot, health, last_run_uid,
+                        updated_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:source_uid AS uuid),
+                        :asset_key, :namespace, :name, :asset_type,
+                        :lifecycle_status, :current_version, :content_hash,
+                        CAST(:snapshot AS jsonb), CAST(:health AS jsonb),
+                        CAST(:last_run_uid AS uuid),
+                        CAST(:updated_at AS timestamptz)
+                    )
+                    ON CONFLICT (source_uid, asset_key) DO UPDATE SET
+                        namespace = EXCLUDED.namespace,
+                        name = EXCLUDED.name,
+                        asset_type = EXCLUDED.asset_type,
+                        lifecycle_status = EXCLUDED.lifecycle_status,
+                        current_version = EXCLUDED.current_version,
+                        content_hash = EXCLUDED.content_hash,
+                        snapshot = EXCLUDED.snapshot,
+                        health = EXCLUDED.health,
+                        last_run_uid = EXCLUDED.last_run_uid,
+                        updated_at = EXCLUDED.updated_at
+                    """
+                ),
+                {
+                    **asset,
+                    "snapshot": _json(asset["snapshot"]),
+                    "health": _json(asset["health"]),
+                },
+            )
+        for version in operation["versions"]:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_asset_versions (
+                        uid, asset_uid, version, run_uid, content_hash,
+                        snapshot, actor_uid, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:asset_uid AS uuid), :version,
+                        CAST(:run_uid AS uuid), :content_hash,
+                        CAST(:snapshot AS jsonb), CAST(:actor_uid AS uuid),
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                {**version, "snapshot": _json(version["snapshot"])},
+            )
+        for change in operation["changes"]:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_changes (
+                        uid, run_uid, asset_uid, asset_key, field_name,
+                        change_type, before_state, after_state, status
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:run_uid AS uuid),
+                        CAST(:asset_uid AS uuid), :asset_key, :field_name,
+                        :change_type, CAST(:before_state AS jsonb),
+                        CAST(:after_state AS jsonb), :status
+                    )
+                    """
+                ),
+                {
+                    **change,
+                    "before_state": (
+                        _json(change["before_state"])
+                        if change["before_state"] is not None
+                        else None
+                    ),
+                    "after_state": (
+                        _json(change["after_state"])
+                        if change["after_state"] is not None
+                        else None
+                    ),
+                },
+            )
+        for lineage in operation["lineage"]:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.active_metadata_lineage (
+                        uid, run_uid, parse_status, source_asset, source_field,
+                        target_asset, target_field, relation_type, evidence,
+                        failure_reason, created_at
+                    ) VALUES (
+                        CAST(:uid AS uuid), CAST(:run_uid AS uuid),
+                        :parse_status, :source_asset, :source_field,
+                        :target_asset, :target_field, :relation_type,
+                        CAST(:evidence AS jsonb), :failure_reason,
+                        CAST(:created_at AS timestamptz)
+                    )
+                    """
+                ),
+                {**lineage, "evidence": _json(lineage["evidence"])},
+            )
+        for signal in operation["health_signals"]:
+            self._insert_signal(signal)
+        self.session.execute(
+            text(
+                """
+                UPDATE public.active_metadata_plans
+                SET cursor_state = CAST(:cursor_state AS jsonb),
+                    updated_at = CURRENT_TIMESTAMP
+                WHERE uid = CAST(:uid AS uuid)
+                """
+            ),
+            {"uid": run["plan_uid"], "cursor_state": _json(run["cursor_after"])},
+        )
+        return self.find_run_by_batch(run["plan_uid"], run["batch_key"])
+
+    def _insert_run(self, run):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.active_metadata_runs (
+                    uid, plan_uid, batch_key, status, attempt_count,
+                    cursor_before, cursor_after, snapshot_hash, statistics,
+                    failure_code, failure_reason, actor_uid,
+                    started_at, finished_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:plan_uid AS uuid), :batch_key,
+                    :status, :attempt_count, CAST(:cursor_before AS jsonb),
+                    CAST(:cursor_after AS jsonb), :snapshot_hash,
+                    CAST(:statistics AS jsonb), :failure_code, :failure_reason,
+                    CAST(:actor_uid AS uuid),
+                    CAST(:started_at AS timestamptz),
+                    CAST(:finished_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **run,
+                "cursor_before": _json(run["cursor_before"]),
+                "cursor_after": _json(run["cursor_after"]),
+                "statistics": _json(run["statistics"]),
+            },
+        )
+
+    def _insert_signal(self, signal):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.active_metadata_health_signals (
+                    uid, asset_uid, run_uid, signal_type, value,
+                    status, evidence, observed_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:asset_uid AS uuid),
+                    CAST(:run_uid AS uuid), :signal_type,
+                    CAST(:value AS jsonb), :status,
+                    CAST(:evidence AS jsonb),
+                    CAST(:observed_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **signal,
+                "value": _json(signal["value"]),
+                "evidence": _json(signal["evidence"]),
+            },
+        )
+
+    def record_failed_run(self, run, signals):
+        self._insert_run(run)
+        for signal in signals:
+            self._insert_signal(signal)
+        return self.find_run_by_batch(run["plan_uid"], run["batch_key"])
+
+    def save_correction(self, correction, audit):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.active_metadata_corrections (
+                    uid, asset_uid, field_name, proposed_value, reason,
+                    assignee_uid, status, resolution, current_version,
+                    submitted_by, created_at, updated_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:asset_uid AS uuid), :field_name,
+                    CAST(:proposed_value AS jsonb), :reason,
+                    CAST(:assignee_uid AS uuid), :status,
+                    CAST(:resolution AS jsonb), :current_version,
+                    CAST(:submitted_by AS uuid),
+                    CAST(:created_at AS timestamptz),
+                    CAST(:updated_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **correction,
+                "proposed_value": _json(correction["proposed_value"]),
+                "resolution": _json(correction["resolution"]),
+            },
+        )
+        self._insert_correction_audit(audit)
+        return self.get_correction(correction["uid"])
+
+    def get_correction(self, uid):
+        return _row(
+            self.session.execute(
+                text(
+                    """
+                    SELECT uid, asset_uid, field_name, proposed_value, reason,
+                           assignee_uid, status, resolution, current_version,
+                           submitted_by, resolved_by, created_at, updated_at
+                    FROM public.active_metadata_corrections
+                    WHERE uid = CAST(:uid AS uuid)
+                    """
+                ),
+                {"uid": uid},
+            )
+            .mappings()
+            .one_or_none()
+        )
+
+    def resolve_correction(self, correction, audit):
+        result = self.session.execute(
+            text(
+                """
+                UPDATE public.active_metadata_corrections
+                SET status = :status,
+                    resolution = CAST(:resolution AS jsonb),
+                    current_version = :current_version,
+                    resolved_by = CAST(:resolved_by AS uuid),
+                    updated_at = CAST(:updated_at AS timestamptz)
+                WHERE uid = CAST(:uid AS uuid)
+                  AND current_version = :expected_version
+                """
+            ),
+            {
+                **correction,
+                "resolution": _json(correction["resolution"]),
+                "expected_version": int(correction["current_version"]) - 1,
+            },
+        )
+        if result.rowcount != 1:
+            raise RuntimeError("correction version conflict")
+        self._insert_correction_audit(audit)
+        return self.get_correction(correction["uid"])
+
+    def _insert_correction_audit(self, audit):
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.active_metadata_correction_audits (
+                    uid, correction_uid, version, action, before_state,
+                    after_state, actor_uid, created_at
+                ) VALUES (
+                    CAST(:uid AS uuid), CAST(:correction_uid AS uuid),
+                    :version, :action, CAST(:before_state AS jsonb),
+                    CAST(:after_state AS jsonb), CAST(:actor_uid AS uuid),
+                    CAST(:created_at AS timestamptz)
+                )
+                """
+            ),
+            {
+                **audit,
+                "before_state": (
+                    _json(audit["before_state"])
+                    if audit["before_state"] is not None
+                    else None
+                ),
+                "after_state": _json(audit["after_state"]),
+            },
+        )
+
+    def list_runs(self, plan_uid):
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    """
+                    SELECT uid, plan_uid, batch_key, status, attempt_count,
+                           cursor_before, cursor_after, snapshot_hash,
+                           statistics, failure_code, failure_reason, actor_uid,
+                           started_at, finished_at, created_at
+                    FROM public.active_metadata_runs
+                    WHERE plan_uid = CAST(:plan_uid AS uuid)
+                    ORDER BY created_at DESC, uid DESC
+                    """
+                ),
+                {"plan_uid": plan_uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_changes(self, run_uid):
+        return self._list_by_run("active_metadata_changes", run_uid)
+
+    def list_lineage(self, run_uid):
+        return self._list_by_run("active_metadata_lineage", run_uid)
+
+    def list_health_signals(self, run_uid):
+        return self._list_by_run("active_metadata_health_signals", run_uid)
+
+    def _list_by_run(self, table, run_uid):
+        allowed = {
+            "active_metadata_changes",
+            "active_metadata_lineage",
+            "active_metadata_health_signals",
+        }
+        if table not in allowed:
+            raise ValueError("unsupported active metadata table")
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    f"SELECT * FROM public.{table} "
+                    "WHERE run_uid = CAST(:run_uid AS uuid) "
+                    "ORDER BY created_at, uid"
+                ),
+                {"run_uid": run_uid},
+            )
+            .mappings()
+            .all()
+        ]
+
+    def list_corrections(self, asset_uid=None):
+        where = (
+            "WHERE asset_uid = CAST(:asset_uid AS uuid)" if asset_uid else ""
+        )
+        return [
+            _row(item)
+            for item in self.session.execute(
+                text(
+                    "SELECT uid, asset_uid, field_name, proposed_value, reason, "
+                    "assignee_uid, status, resolution, current_version, "
+                    "submitted_by, resolved_by, created_at, updated_at "
+                    f"FROM public.active_metadata_corrections {where} "
+                    "ORDER BY created_at DESC, uid DESC"
+                ),
+                {"asset_uid": asset_uid} if asset_uid else {},
+            )
+            .mappings()
+            .all()
+        ]

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

@@ -49,6 +49,9 @@ GOVERNANCE_AUDIT_SEAL = "governance-audit:seal"
 DOMAIN_TEMPLATES_READ = "domain-templates:read"
 DOMAIN_TEMPLATES_PREVIEW = "domain-templates:preview"
 DOMAIN_TEMPLATES_MANAGE = "domain-templates:manage"
+ACTIVE_METADATA_READ = "active-metadata:read"
+ACTIVE_METADATA_OPERATE = "active-metadata:operate"
+ACTIVE_METADATA_MANAGE = "active-metadata:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -57,6 +60,7 @@ ROLE_PERMISSIONS = {
             RULES_READ,
             RESPONSIBILITIES_READ,
             DOMAIN_TEMPLATES_READ,
+            ACTIVE_METADATA_READ,
         }
     ),
     "editor": frozenset(
@@ -81,6 +85,8 @@ ROLE_PERMISSIONS = {
             DEVICE_OBSERVABILITY_EDIT,
             DOMAIN_TEMPLATES_READ,
             DOMAIN_TEMPLATES_PREVIEW,
+            ACTIVE_METADATA_READ,
+            ACTIVE_METADATA_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -127,6 +133,9 @@ ROLE_PERMISSIONS = {
             DOMAIN_TEMPLATES_READ,
             DOMAIN_TEMPLATES_PREVIEW,
             DOMAIN_TEMPLATES_MANAGE,
+            ACTIVE_METADATA_READ,
+            ACTIVE_METADATA_OPERATE,
+            ACTIVE_METADATA_MANAGE,
         }
     ),
 }
@@ -153,6 +162,12 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path == "/api/meta/domain-templates/dry-run":
             return (DOMAIN_TEMPLATES_PREVIEW,)
         return (DOMAIN_TEMPLATES_MANAGE,)
+    if path.startswith("/api/meta/active-metadata"):
+        if method == "GET":
+            return (ACTIVE_METADATA_READ,)
+        if path == "/api/meta/active-metadata/plans":
+            return (ACTIVE_METADATA_MANAGE,)
+        return (ACTIVE_METADATA_OPERATE,)
     if path in {"/api/knowledge/search", "/api/knowledge/ask"}:
         return (READ_GOVERNANCE,)
     if path.startswith("/api/rules"):

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

@@ -1,5 +1,16 @@
 # Models package initialization
 
+from app.models.active_metadata import (
+    ActiveMetadataAsset,
+    ActiveMetadataAssetVersion,
+    ActiveMetadataChange,
+    ActiveMetadataCorrection,
+    ActiveMetadataCorrectionAudit,
+    ActiveMetadataHealthSignal,
+    ActiveMetadataLineage,
+    ActiveMetadataPlan,
+    ActiveMetadataRun,
+)
 from app.models.data_product import DataOrder, DataProduct
 from app.models.data_research import (
     CandidateDecisionRecord,
@@ -25,6 +36,15 @@ from app.models.governance_template import (
 from app.models.metadata_review import MetadataReviewRecord, MetadataVersionHistory
 
 __all__ = [
+    "ActiveMetadataPlan",
+    "ActiveMetadataRun",
+    "ActiveMetadataAsset",
+    "ActiveMetadataAssetVersion",
+    "ActiveMetadataChange",
+    "ActiveMetadataLineage",
+    "ActiveMetadataHealthSignal",
+    "ActiveMetadataCorrection",
+    "ActiveMetadataCorrectionAudit",
     "DataOrder",
     "DataProduct",
     "MetadataReviewRecord",

+ 160 - 0
deployment/app/models/active_metadata.py

@@ -0,0 +1,160 @@
+"""Persistence models for active metadata discovery and field lineage."""
+
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+
+from app import db
+from app.core.common.identifiers import new_governance_uid
+
+
+class ActiveMetadataPlan(db.Model):
+    __tablename__ = "active_metadata_plans"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    source_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    name = db.Column(db.String(300), nullable=False)
+    source_kind = db.Column(db.String(20), nullable=False)
+    schedule_type = db.Column(db.String(20), nullable=False)
+    schedule_expression = db.Column(db.String(120))
+    discovery_mode = db.Column(db.String(20), nullable=False)
+    scope = db.Column(JSONB, nullable=False)
+    cursor_state = db.Column(JSONB, nullable=False)
+    owner_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    enabled = db.Column(db.Boolean, nullable=False)
+    current_version = db.Column(db.Integer, nullable=False)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class ActiveMetadataRun(db.Model):
+    __tablename__ = "active_metadata_runs"
+    __table_args__ = (
+        db.UniqueConstraint("plan_uid", "batch_key"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    plan_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    batch_key = db.Column(db.String(160), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    attempt_count = db.Column(db.Integer, nullable=False)
+    cursor_before = db.Column(JSONB, nullable=False)
+    cursor_after = db.Column(JSONB, nullable=False)
+    snapshot_hash = db.Column(db.String(64))
+    statistics = db.Column(JSONB, nullable=False)
+    failure_code = db.Column(db.String(80))
+    failure_reason = db.Column(db.String(500))
+    actor_uid = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class ActiveMetadataAsset(db.Model):
+    __tablename__ = "active_metadata_assets"
+    __table_args__ = (
+        db.UniqueConstraint("source_uid", "asset_key"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True)
+    source_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    asset_key = db.Column(db.String(500), nullable=False)
+    namespace = db.Column(db.String(200), nullable=False)
+    name = db.Column(db.String(200), nullable=False)
+    asset_type = db.Column(db.String(40), nullable=False)
+    lifecycle_status = db.Column(db.String(30), nullable=False)
+    current_version = db.Column(db.Integer, nullable=False)
+    content_hash = db.Column(db.String(64), nullable=False)
+    snapshot = db.Column(JSONB, nullable=False)
+    health = db.Column(JSONB, nullable=False)
+    last_run_uid = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class ActiveMetadataAssetVersion(db.Model):
+    __tablename__ = "active_metadata_asset_versions"
+    __table_args__ = (
+        db.UniqueConstraint("asset_uid", "version"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    version = db.Column(db.Integer, nullable=False)
+    run_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    content_hash = db.Column(db.String(64), nullable=False)
+    snapshot = db.Column(JSONB, nullable=False)
+    actor_uid = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class ActiveMetadataChange(db.Model):
+    __tablename__ = "active_metadata_changes"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    run_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    asset_key = db.Column(db.String(500), nullable=False)
+    field_name = db.Column(db.String(200))
+    change_type = db.Column(db.String(40), nullable=False)
+    before_state = db.Column(JSONB)
+    after_state = db.Column(JSONB)
+    status = db.Column(db.String(20), nullable=False)
+
+
+class ActiveMetadataLineage(db.Model):
+    __tablename__ = "active_metadata_lineage"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    run_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    parse_status = db.Column(db.String(20), nullable=False)
+    source_asset = db.Column(db.String(500))
+    source_field = db.Column(db.String(200))
+    target_asset = db.Column(db.String(500))
+    target_field = db.Column(db.String(200))
+    relation_type = db.Column(db.String(40), nullable=False)
+    evidence = db.Column(JSONB, nullable=False)
+    failure_reason = db.Column(db.String(500))
+
+
+class ActiveMetadataHealthSignal(db.Model):
+    __tablename__ = "active_metadata_health_signals"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    run_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    signal_type = db.Column(db.String(30), nullable=False)
+    value = db.Column(JSONB)
+    status = db.Column(db.String(20), nullable=False)
+    evidence = db.Column(JSONB, nullable=False)
+
+
+class ActiveMetadataCorrection(db.Model):
+    __tablename__ = "active_metadata_corrections"
+    __table_args__ = {"schema": "public"}
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    asset_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    field_name = db.Column(db.String(200))
+    proposed_value = db.Column(JSONB, nullable=False)
+    reason = db.Column(db.String(500), nullable=False)
+    assignee_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    resolution = db.Column(JSONB, nullable=False)
+    current_version = db.Column(db.Integer, nullable=False)
+    submitted_by = db.Column(UUID(as_uuid=False), nullable=False)
+    resolved_by = db.Column(UUID(as_uuid=False))
+
+
+class ActiveMetadataCorrectionAudit(db.Model):
+    __tablename__ = "active_metadata_correction_audits"
+    __table_args__ = (
+        db.UniqueConstraint("correction_uid", "version"),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    correction_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    version = db.Column(db.Integer, nullable=False)
+    action = db.Column(db.String(40), nullable=False)
+    before_state = db.Column(JSONB)
+    after_state = db.Column(JSONB, nullable=False)
+    actor_uid = db.Column(UUID(as_uuid=False), nullable=False)

+ 5 - 5
docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md

@@ -220,11 +220,11 @@ P2-WP10 和 P2-WP11 为贯穿性工作包,从第 1 周开始建立门禁,在
 
 **主要工作:**
 
-- [ ] 为数据库和受控文件/API 来源建立自动发现计划。
-- [ ] 支持快照差异或游标增量采集,记录新增、变更、删除候选。
-- [ ] 采集或解析表级、字段级血缘,并保留证据和解析失败原因。
-- [ ] 将质量、新鲜度、任务失败和使用热度回写为资产健康信号。
-- [ ] 建立用户纠错、责任人处置和版本审计。
+- [x] 为数据库和受控文件/API 来源建立自动发现计划。
+- [x] 支持快照差异或游标增量采集,记录新增、变更、删除候选。
+- [x] 采集或解析表级、字段级血缘,并保留证据和解析失败原因。
+- [x] 将质量、新鲜度、任务失败和使用热度回写为资产健康信号。
+- [x] 建立用户纠错、责任人处置和版本审计。
 
 **主要文件区域:**
 

+ 24 - 0
docs/architecture/DATA_MODEL.md

@@ -282,6 +282,29 @@ P2-WP00 默认的“备品备件/物料主数据”模板包含物料、物料
 指标和来源/术语/流程初始化契约。企业真实连接、样本和人员仍需在 P2-WP12 前绑定,模板
 不保存密码、令牌、连接串或真实网络地址。
 
+## 4.4 P2-WP02 主动元数据、字段血缘与纠错
+
+主动元数据保留现有目录采集结果,并将跨批次当前态、不可变版本和变化证据持久化到
+PostgreSQL。数据库目录采集产生新快照后,在同一事务中投影到匹配数据源的启用计划;
+受控文件和 API 来源使用相同计划与批次契约,由其采集器提交规范化快照。每个计划使用
+`plan_uid + batch_key` 保证幂等,游标只在成功批次后推进。
+
+| 数据对象 | 作用 | 关键约束 |
+|---|---|---|
+| `active_metadata_plans` | 数据库、文件、API 的发现计划 | 来源、调度、发现模式、范围、游标和责任人显式记录 |
+| `active_metadata_runs` | 每次发现批次 | 计划内批次键唯一,保存游标前后值、内容哈希、统计和失败原因 |
+| `active_metadata_assets` | 来源资产当前态 | 来源与资产键唯一;缺失资产标记为 `deletion_candidate`,不物理删除 |
+| `active_metadata_asset_versions` | 资产不可变版本 | 内容变化才追加版本,并关联发现批次和操作者 |
+| `active_metadata_changes` | 资产与字段增量证据 | 记录新增、字段变化、字段删除候选和资产删除候选 |
+| `active_metadata_lineage` | 表级/字段级血缘 | 解析成功保存字段边;失败保存语句哈希和失败原因 |
+| `active_metadata_health_signals` | 资产健康时间序列 | 仅接受质量、新鲜度、任务失败和使用热度四类信号 |
+| `active_metadata_corrections` | 用户纠错当前态 | 指定责任人处置并采用乐观版本控制 |
+| `active_metadata_correction_audits` | 纠错不可变审计 | 提交和处置逐版本追加,不覆盖历史 |
+
+主动发现不会直接覆盖 Neo4j 中已发布的治理元数据。发现变化先以候选和证据形式留在
+PostgreSQL;用户纠错也只形成待责任人处置的版本化记录。后续发布仍需走既有评审和发布
+门禁。SQL 字段血缘当前采用保守解析:无法可靠解析时失败关闭并保留原因,不猜测字段关系。
+
 ## 5. 所有权与删除规则
 
 - PostgreSQL 是身份、权限、映射、任务状态、布局和一致性事件的源真相。
@@ -299,6 +322,7 @@ P2-WP00 默认的“备品备件/物料主数据”模板包含物料、物料
 - 治理运营指标是 PostgreSQL canonical 数据的实时只读查询投影;不保存人工覆盖值。指标汇总与明细必须使用同一业务域授权边界,跨域合并只有两端均可见时才能计入非管理员结果。
 - 审计中心只读取六类现有权威记录的安全投影;`governance_audit_seals` 只追加封存摘要和签名,不接收原始问题、凭据、来源配置、自由文本备注或证据正文。
 - 领域模板、模板版本、通用对象类型和导入审计以 PostgreSQL 为源真相;模板只描述对象契约,不替代设备台账或复制第二套资产服务。模板回滚追加新版本,被移除对象类型只退役、不删除。
+- 主动元数据计划、批次、资产当前态、不可变版本、变化候选、字段血缘、健康信号和纠错审计以 PostgreSQL 为源真相;现有目录快照继续作为批次输入证据。删除只形成候选,解析失败保留原因,发现或纠错不能绕过既有发布门禁覆盖 Neo4j 已发布元数据。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 278 - 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: 225
+x-route-count: 236
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -4562,6 +4562,283 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/meta/active-metadata/assets":
+    get:
+      tags: [meta_data]
+      operationId: meta_data_list_active_metadata_assets_get
+      summary: "list active metadata assets"
+      x-source: "app/api/meta_data/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/meta/active-metadata/assets/{asset_uid}/corrections":
+    post:
+      tags: [meta_data]
+      operationId: meta_data_submit_active_metadata_correction_post
+      summary: "submit active metadata correction"
+      x-source: "app/api/meta_data/routes.py"
+      parameters:
+        - name: asset_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/meta/active-metadata/corrections":
+    get:
+      tags: [meta_data]
+      operationId: meta_data_list_active_metadata_corrections_get
+      summary: "list active metadata corrections"
+      x-source: "app/api/meta_data/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/meta/active-metadata/corrections/{correction_uid}/resolve":
+    post:
+      tags: [meta_data]
+      operationId: meta_data_resolve_active_metadata_correction_post
+      summary: "resolve active metadata correction"
+      x-source: "app/api/meta_data/routes.py"
+      parameters:
+        - name: correction_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/meta/active-metadata/plans":
+    get:
+      tags: [meta_data]
+      operationId: meta_data_list_active_metadata_plans_get
+      summary: "list active metadata plans"
+      x-source: "app/api/meta_data/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: [meta_data]
+      operationId: meta_data_create_active_metadata_plan_post
+      summary: "create active metadata plan"
+      x-source: "app/api/meta_data/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/meta/active-metadata/plans/{plan_uid}/runs":
+    get:
+      tags: [meta_data]
+      operationId: meta_data_list_active_metadata_runs_get
+      summary: "list active metadata runs"
+      x-source: "app/api/meta_data/routes.py"
+      parameters:
+        - name: plan_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [meta_data]
+      operationId: meta_data_execute_active_metadata_plan_post
+      summary: "execute active metadata plan"
+      x-source: "app/api/meta_data/routes.py"
+      parameters:
+        - name: plan_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/meta/active-metadata/runs/{run_uid}/changes":
+    get:
+      tags: [meta_data]
+      operationId: meta_data_list_active_metadata_changes_get
+      summary: "list active metadata changes"
+      x-source: "app/api/meta_data/routes.py"
+      parameters:
+        - name: run_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/meta/active-metadata/runs/{run_uid}/health-signals":
+    get:
+      tags: [meta_data]
+      operationId: meta_data_list_active_metadata_health_get
+      summary: "list active metadata health"
+      x-source: "app/api/meta_data/routes.py"
+      parameters:
+        - name: run_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/meta/active-metadata/runs/{run_uid}/lineage":
+    get:
+      tags: [meta_data]
+      operationId: meta_data_list_active_metadata_lineage_get
+      summary: "list active metadata lineage"
+      x-source: "app/api/meta_data/routes.py"
+      parameters:
+        - name: run_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/meta/check":
     get:
       tags: [meta_data]

+ 57 - 0
docs/validation/P2_WP02_ACTIVE_METADATA_EVIDENCE.md

@@ -0,0 +1,57 @@
+# P2-WP02 主动元数据与字段血缘验证记录
+
+## 1. 工程范围
+
+P2-WP02 复用既有数据库目录采集和字段证据,在其上增加持续发现与治理闭环:
+
+1. 数据库、受控文件和 API 来源共用的发现计划、调度、范围和游标契约;
+2. 数据库目录快照自动投影到匹配来源的启用计划,PostgreSQL 和 MySQL 继续复用既有采集器;
+3. 跨批次资产当前态、不可变版本,以及资产/字段新增、变更和删除候选;
+4. 已纳管 SQL 的保守字段血缘解析,以及不可解析语句的哈希证据和失败原因;
+5. 质量、新鲜度、任务失败和使用热度四类资产健康信号;
+6. 用户纠错、指定责任人处置、乐观版本控制和追加式审计;
+7. viewer 只读、editor 执行与纠错、admin 计划管理的独立权限和运营页面。
+
+本工作包没有复制第二套目录采集服务,也没有改变既有 Neo4j 已发布元数据。受控文件/API
+通过同一计划和批次接口提交规范化快照;第二业务域真实连接和样本仍按 P2-WP12 绑定验收。
+
+## 2. 数据与事务边界
+
+- `plan_uid + batch_key` 唯一;重复请求返回原批次,不重复追加版本、变化、血缘或健康信号;
+- 数据库目录快照与主动元数据投影在同一应用事务中完成,投影失败会使采集任务进入可重试失败态;
+- 游标只在成功批次后推进;失败批次保留原游标和失败原因;
+- 资产内容变化才追加不可变版本;当前快照缺失只标记资产或字段为删除候选,不物理删除;
+- 字段血缘无法可靠解析时失败关闭并保存原因,不猜测字段关系;
+- 健康信号只接受质量、新鲜度、任务失败和使用热度四类;
+- 纠错只能由指定责任人按期望版本处置,提交和处置均追加审计版本;
+- 计划、快照和纠错载荷递归拒绝密码、令牌、凭据、API Key 和私钥语义字段;
+- 主动发现只形成 PostgreSQL 候选和证据,不绕过既有评审/发布门禁覆盖 Neo4j 元数据。
+
+## 3. 接口与页面
+
+新增 11 个 `/api/meta/active-metadata` 操作,覆盖计划、批次、资产、变化、字段血缘、健康信号
+和纠错。OpenAPI 已由当前路由重新生成,共 236 个操作。
+
+“主动元数据与血缘”页面从数据研发中心进入,可以追溯计划、批次、来源资产、删除候选、
+字段变化、解析失败原因、健康信号和纠错审计。只有管理员显示新建计划入口。
+
+## 4. 定向验证结果(2026-07-30)
+
+遵循工作包级验证策略,本次未执行全量回归,只验证 P2-WP02 和直接触达能力:
+
+- 核心、API、迁移、前端契约和目录投影专项测试 19 项通过;
+- 既有目录采集 API 兼容测试 4 项通过;
+- OpenAPI 可复现和路由清单测试 2 项通过;
+- 本地 PostgreSQL 主动元数据真实持久化集成测试 1 项通过;
+- PostgreSQL、MySQL 真实目录采集集成测试 2 项通过;
+- 本地 Alembic 位于 `20260730_380 (head)`;
+- 新增和触达 Python 文件 Ruff 检查通过;
+- 新增和触达前端文件 ESLint 检查通过;
+- 前端生产构建成功,仅保留项目既有的 Browserslist 数据过期和 Webpack 弃用警告;
+- `app/` 与 `deployment/app/` 的新增和触达后端文件逐字节一致。
+
+真实持久化验证覆盖:启用来源计划自动投影、同批次重放幂等、跨批次字段类型变化、资产删除
+候选、游标推进、成功/失败字段血缘、四类健康信号、失败批次、责任人纠错和两版审计。
+
+以上是本地工程完成证据,不代表第二业务域真实文件/API 样本、企业人员、目标环境调度器或
+生产发布已经验收;这些外部绑定仍由 P2-WP12、P2-WP13 完成。

+ 19 - 0
frontend/src/api/activeMetadata.js

@@ -0,0 +1,19 @@
+import http from '@/utils/request'
+
+const BASE = '/meta/active-metadata'
+const PLANS = '/meta/active-metadata/plans'
+
+export const getActiveMetadataPlans = () => http.get(PLANS)
+export const createActiveMetadataPlan = payload => http.post(PLANS, payload)
+export const getActiveMetadataRuns = planUid => http.get(`${PLANS}/${planUid}/runs`)
+export const getActiveMetadataAssets = sourceUid => http.get(
+  `${BASE}/assets`,
+  { params: { source_uid: sourceUid } }
+)
+export const getActiveMetadataChanges = runUid => http.get(`${BASE}/runs/${runUid}/changes`)
+export const getActiveMetadataLineage = runUid => http.get(`${BASE}/runs/${runUid}/lineage`)
+export const getActiveMetadataHealth = runUid => http.get(`${BASE}/runs/${runUid}/health-signals`)
+export const getActiveMetadataCorrections = assetUid => http.get(
+  `${BASE}/corrections`,
+  { params: assetUid ? { asset_uid: assetUid } : {} }
+)

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

@@ -216,6 +216,18 @@ export default {
           name: 'dataResearchTasks',
           alwaysShow: 0
         },
+        {
+          hidden: 1,
+          type: 1,
+          title: '主动元数据与血缘',
+          path: '/data-governance/development/active-metadata',
+          children: [],
+          label: '主动元数据与血缘',
+          component: 'dataGovernance/development/activeMetadata',
+          meta: { roles: ['viewer', 'editor', 'admin'], title: '主动元数据与血缘', readOnly: 'viewer' },
+          name: 'activeMetadata',
+          alwaysShow: 0
+        },
         {
           hidden: 1,
           type: 1,

+ 395 - 0
frontend/src/views/dataGovernance/development/activeMetadata.vue

@@ -0,0 +1,395 @@
+<template>
+  <div class="pa-6 active-metadata">
+    <div class="d-flex flex-wrap align-start mb-5">
+      <div>
+        <h1 class="text-h4 mb-1">主动元数据与血缘</h1>
+        <p class="text--secondary mb-0">
+          按发现计划持续追踪元数据增量、字段血缘、健康信号与人工纠错。
+        </p>
+      </div>
+      <v-spacer />
+      <v-btn outlined color="primary" class="mr-3" :loading="loading" @click="loadPlans">
+        刷新
+      </v-btn>
+      <v-btn v-if="isAdmin" color="primary" @click="planDialog = true">
+        新建发现计划
+      </v-btn>
+    </div>
+
+    <v-alert type="info" outlined>
+      重复批次按幂等键复用结果;来源缺失只标记为删除候选,不会静默覆盖已发布元数据。
+      viewer 可查看,editor 可执行采集与提交纠错,仅 admin 可创建计划。
+    </v-alert>
+
+    <v-row>
+      <v-col cols="12" lg="5">
+        <v-card outlined height="100%">
+          <v-card-title>发现计划</v-card-title>
+          <v-data-table
+            :headers="planHeaders"
+            :items="plans"
+            :loading="loading"
+            hide-default-footer
+            @click:row="selectPlan"
+          >
+            <template v-slot:[`item.enabled`]="{ item }">
+              <v-chip small outlined :color="item.enabled ? 'success' : 'grey'">
+                {{ item.enabled ? '启用' : '停用' }}
+              </v-chip>
+            </template>
+            <template v-slot:[`item.actions`]="{ item }">
+              <v-btn text small color="primary" @click.stop="selectPlan(item)">查看</v-btn>
+            </template>
+            <template #no-data>
+              <div class="py-7 text--secondary">尚未配置主动发现计划</div>
+            </template>
+          </v-data-table>
+        </v-card>
+      </v-col>
+
+      <v-col cols="12" lg="7">
+        <v-card outlined height="100%">
+          <v-card-title>
+            发现批次
+            <span v-if="selectedPlan" class="text--secondary subtitle-2 ml-2">
+              {{ selectedPlan.name }}
+            </span>
+          </v-card-title>
+          <v-data-table
+            :headers="runHeaders"
+            :items="runs"
+            :loading="detailLoading"
+            hide-default-footer
+          >
+            <template v-slot:[`item.status`]="{ item }">
+              <v-chip small outlined :color="statusColor(item.status)">
+                {{ statusLabel(item.status) }}
+              </v-chip>
+            </template>
+            <template v-slot:[`item.started_at`]="{ item }">
+              {{ formatTime(item.started_at) }}
+            </template>
+            <template v-slot:[`item.actions`]="{ item }">
+              <v-btn text small color="primary" @click="selectRun(item)">追溯</v-btn>
+            </template>
+            <template #no-data>
+              <div class="py-7 text--secondary">请选择计划,或该计划尚无批次</div>
+            </template>
+          </v-data-table>
+        </v-card>
+      </v-col>
+    </v-row>
+
+    <v-card v-if="selectedPlan" outlined class="mt-5">
+      <v-card-title>来源资产</v-card-title>
+      <v-data-table :headers="assetHeaders" :items="assets" :loading="detailLoading">
+        <template v-slot:[`item.lifecycle_status`]="{ item }">
+          <v-chip
+            small
+            outlined
+            :color="item.lifecycle_status === 'deletion_candidate' ? 'warning' : 'success'"
+          >
+            {{ item.lifecycle_status === 'deletion_candidate' ? '删除候选' : '有效' }}
+          </v-chip>
+        </template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <v-btn text small color="primary" @click="loadCorrections(item.uid)">
+            纠错审计
+          </v-btn>
+        </template>
+      </v-data-table>
+    </v-card>
+
+    <v-card v-if="selectedRun" outlined class="mt-5">
+      <v-card-title>
+        批次追溯
+        <span class="text--secondary subtitle-2 ml-2">{{ selectedRun.batch_key }}</span>
+      </v-card-title>
+      <v-tabs v-model="tab" show-arrows>
+        <v-tab>增量变化</v-tab>
+        <v-tab>字段血缘</v-tab>
+        <v-tab>健康信号</v-tab>
+      </v-tabs>
+      <v-tabs-items v-model="tab">
+        <v-tab-item>
+          <v-data-table :headers="changeHeaders" :items="changes" hide-default-footer>
+            <template v-slot:[`item.change_type`]="{ item }">
+              {{ changeLabel(item.change_type) }}
+            </template>
+          </v-data-table>
+        </v-tab-item>
+        <v-tab-item>
+          <v-data-table :headers="lineageHeaders" :items="lineage" hide-default-footer>
+            <template v-slot:[`item.parse_status`]="{ item }">
+              <v-chip small outlined :color="item.parse_status === 'resolved' ? 'success' : 'error'">
+                {{ item.parse_status === 'resolved' ? '已解析' : '解析失败' }}
+              </v-chip>
+            </template>
+            <template v-slot:[`item.failure_reason`]="{ item }">
+              {{ item.failure_reason || '-' }}
+            </template>
+          </v-data-table>
+          <div class="px-4 pb-3 text-caption text--secondary">
+            解析失败原因会随批次保留,便于修复后重试,不会被丢弃。
+          </div>
+        </v-tab-item>
+        <v-tab-item>
+          <v-data-table :headers="healthHeaders" :items="health" hide-default-footer />
+        </v-tab-item>
+      </v-tabs-items>
+    </v-card>
+
+    <v-dialog v-model="correctionDialog" max-width="980">
+      <v-card>
+        <v-card-title>纠错审计</v-card-title>
+        <v-data-table :headers="correctionHeaders" :items="corrections">
+          <template v-slot:[`item.created_at`]="{ item }">
+            {{ formatTime(item.created_at) }}
+          </template>
+        </v-data-table>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="correctionDialog = false">关闭</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="planDialog" max-width="640">
+      <v-card>
+        <v-card-title>新建主动发现计划</v-card-title>
+        <v-card-text>
+          <v-text-field v-model.trim="form.name" label="计划名称 *" />
+          <v-text-field v-model.trim="form.source_uid" label="数据源 UID *" />
+          <v-text-field v-model.trim="form.owner_uid" label="责任人 UID *" />
+          <v-select
+            v-model="form.source_kind"
+            :items="sourceKinds"
+            item-text="label"
+            item-value="value"
+            label="来源类型 *"
+          />
+          <v-select
+            v-model="form.discovery_mode"
+            :items="discoveryModes"
+            item-text="label"
+            item-value="value"
+            label="发现模式 *"
+          />
+          <v-text-field v-model.trim="form.schedule_expression" label="调度表达式 *" />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="planDialog = false">取消</v-btn>
+          <v-btn color="primary" :loading="saving" @click="createPlan">保存计划</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  createActiveMetadataPlan,
+  getActiveMetadataAssets,
+  getActiveMetadataChanges,
+  getActiveMetadataCorrections,
+  getActiveMetadataHealth,
+  getActiveMetadataLineage,
+  getActiveMetadataPlans,
+  getActiveMetadataRuns
+} from '@/api/activeMetadata'
+
+const emptyForm = () => ({
+  name: '',
+  source_uid: '',
+  owner_uid: '',
+  source_kind: 'database',
+  schedule_type: 'interval',
+  schedule_expression: 'PT30M',
+  discovery_mode: 'cursor',
+  scope: {}
+})
+
+export default {
+  name: 'ActiveMetadata',
+  data: () => ({
+    loading: false,
+    detailLoading: false,
+    saving: false,
+    planDialog: false,
+    correctionDialog: false,
+    tab: 0,
+    plans: [],
+    runs: [],
+    assets: [],
+    changes: [],
+    lineage: [],
+    health: [],
+    corrections: [],
+    selectedPlan: null,
+    selectedRun: null,
+    form: emptyForm(),
+    sourceKinds: [
+      { label: '数据库', value: 'database' },
+      { label: '受控文件', value: 'file' },
+      { label: 'API', value: 'api' }
+    ],
+    discoveryModes: [
+      { label: '游标增量', value: 'cursor' },
+      { label: '快照差异', value: 'snapshot' }
+    ],
+    planHeaders: [
+      { text: '计划', value: 'name' },
+      { text: '来源', value: 'source_kind' },
+      { text: '模式', value: 'discovery_mode' },
+      { text: '状态', value: 'enabled' },
+      { text: '操作', value: 'actions', sortable: false }
+    ],
+    runHeaders: [
+      { text: '批次键', value: 'batch_key' },
+      { text: '状态', value: 'status' },
+      { text: '新增资产', value: 'statistics.asset_added' },
+      { text: '字段变更', value: 'statistics.field_changed' },
+      { text: '时间', value: 'started_at' },
+      { text: '操作', value: 'actions', sortable: false }
+    ],
+    assetHeaders: [
+      { text: '命名空间', value: 'namespace' },
+      { text: '资产', value: 'name' },
+      { text: '类型', value: 'asset_type' },
+      { text: '版本', value: 'current_version' },
+      { text: '状态', value: 'lifecycle_status' },
+      { text: '操作', value: 'actions', sortable: false }
+    ],
+    changeHeaders: [
+      { text: '变化类型', value: 'change_type' },
+      { text: '资产键', value: 'asset_key' },
+      { text: '字段', value: 'field_name' }
+    ],
+    lineageHeaders: [
+      { text: '上游字段', value: 'source_field' },
+      { text: '下游字段', value: 'target_field' },
+      { text: '状态', value: 'parse_status' },
+      { text: '解析失败原因', value: 'failure_reason' }
+    ],
+    healthHeaders: [
+      { text: '资产键', value: 'asset_key' },
+      { text: '信号类型', value: 'signal_type' },
+      { text: '数值', value: 'value' },
+      { text: '状态', value: 'status' }
+    ],
+    correctionHeaders: [
+      { text: '字段', value: 'field_name' },
+      { text: '原因', value: 'reason' },
+      { text: '状态', value: 'status' },
+      { text: '责任人', value: 'assignee_uid' },
+      { text: '版本', value: 'current_version' },
+      { text: '提交时间', value: 'created_at' }
+    ]
+  }),
+  computed: {
+    roles () {
+      return this.$store.state.user.userInfo.roles || []
+    },
+    isAdmin () {
+      return this.roles.includes('admin')
+    }
+  },
+  created () {
+    this.loadPlans()
+  },
+  methods: {
+    async loadPlans () {
+      this.loading = true
+      try {
+        const response = await getActiveMetadataPlans()
+        this.plans = response.data || []
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loading = false
+      }
+    },
+    async selectPlan (plan) {
+      this.selectedPlan = plan
+      this.selectedRun = null
+      this.detailLoading = true
+      try {
+        const [runs, assets] = await Promise.all([
+          getActiveMetadataRuns(plan.uid),
+          getActiveMetadataAssets(plan.source_uid)
+        ])
+        this.runs = runs.data || []
+        this.assets = assets.data || []
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.detailLoading = false
+      }
+    },
+    async selectRun (run) {
+      this.selectedRun = run
+      this.detailLoading = true
+      try {
+        const [changes, lineage, health] = await Promise.all([
+          getActiveMetadataChanges(run.uid),
+          getActiveMetadataLineage(run.uid),
+          getActiveMetadataHealth(run.uid)
+        ])
+        this.changes = changes.data || []
+        this.lineage = lineage.data || []
+        this.health = health.data || []
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.detailLoading = false
+      }
+    },
+    async loadCorrections (assetUid) {
+      try {
+        const response = await getActiveMetadataCorrections(assetUid)
+        this.corrections = response.data || []
+        this.correctionDialog = true
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    async createPlan () {
+      if (!this.form.name || !this.form.source_uid || !this.form.owner_uid) {
+        this.$snackbar.error('请填写计划名称、数据源 UID 和责任人 UID')
+        return
+      }
+      this.saving = true
+      try {
+        await createActiveMetadataPlan(this.form)
+        this.$snackbar.success('主动发现计划已创建')
+        this.planDialog = false
+        this.form = emptyForm()
+        await this.loadPlans()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    formatTime (value) {
+      return value ? new Date(value).toLocaleString() : '-'
+    },
+    statusColor (status) {
+      return status === 'completed' ? 'success' : status === 'failed' ? 'error' : 'primary'
+    },
+    statusLabel (status) {
+      return { completed: '成功', failed: '失败', running: '执行中' }[status] || status
+    },
+    changeLabel (changeType) {
+      return {
+        asset_added: '新增资产',
+        field_added: '新增字段',
+        field_changed: '字段变更',
+        field_removed: '字段移除',
+        deletion_candidate: '删除候选'
+      }[changeType] || changeType
+    }
+  }
+}
+</script>

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

@@ -22,6 +22,7 @@ export default {
     entries: [
       { title: '新建采集', description: '数据库目录、DDL 与文件解析', icon: 'mdi-database-import-outline', path: '/data-governance/development/ingestion', writeOnly: true },
       { title: '研发任务', description: '查看解析进度、失败阶段与重试状态', icon: 'mdi-progress-clock', path: '/data-governance/development/tasks' },
+      { title: '主动元数据与血缘', description: '持续发现元数据增量、字段血缘、健康信号与纠错审计', icon: 'mdi-database-search-outline', path: '/data-governance/development/active-metadata' },
       { title: '治理评审', description: '证据预览、批量决策与数据元素生命周期', icon: 'mdi-clipboard-check-outline', path: '/data-governance/development/review', writeOnly: true },
       { title: '设备台账', description: '设备、部件、测点、告警与维护记录统一追溯', icon: 'mdi-factory', path: '/data-governance/development/device-assets' },
       { title: '领域模板', description: '配置通用对象类型、责任、规则、指标与初始化数据', icon: 'mdi-shape-plus-outline', path: '/data-governance/development/domain-templates' },

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

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

+ 400 - 0
tests/core/data_source/test_active_metadata.py

@@ -0,0 +1,400 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from datetime import UTC, datetime
+
+import pytest
+
+ACTOR_UID = "01900000-0000-7000-8000-000000000101"
+OWNER_UID = "01900000-0000-7000-8000-000000000102"
+SOURCE_UID = "01900000-0000-7000-8000-000000000201"
+
+
+def catalog_snapshot(*, include_email=True, email_type="varchar", include_orders=True):
+    customer_fields = [
+        {"name": "id", "data_type": "bigint", "nullable": False, "ordinal_position": 1},
+        {"name": "name", "data_type": "varchar", "nullable": False, "ordinal_position": 2},
+    ]
+    if include_email:
+        customer_fields.append(
+            {
+                "name": "email",
+                "data_type": email_type,
+                "nullable": True,
+                "ordinal_position": 3,
+            }
+        )
+    assets = [
+        {
+            "key": f"{SOURCE_UID}:public.customers",
+            "namespace": "public",
+            "name": "customers",
+            "asset_type": "table",
+            "fields": customer_fields,
+        }
+    ]
+    if include_orders:
+        assets.append(
+            {
+                "key": f"{SOURCE_UID}:public.orders",
+                "namespace": "public",
+                "name": "orders",
+                "asset_type": "table",
+                "fields": [
+                    {"name": "id", "data_type": "bigint", "nullable": False, "ordinal_position": 1},
+                    {
+                        "name": "customer_id",
+                        "data_type": "bigint",
+                        "nullable": False,
+                        "ordinal_position": 2,
+                    },
+                ],
+            }
+        )
+    return {"assets": assets}
+
+
+class MemoryRepository:
+    def __init__(self):
+        self.plans = {}
+        self.runs = {}
+        self.assets = {}
+        self.versions = []
+        self.changes = []
+        self.lineage = []
+        self.signals = []
+        self.corrections = {}
+        self.audits = []
+
+    def save_plan(self, plan):
+        self.plans[plan["uid"]] = deepcopy(plan)
+        return deepcopy(plan)
+
+    def get_plan(self, uid):
+        return deepcopy(self.plans.get(uid))
+
+    def list_plans(self):
+        return [deepcopy(item) for item in self.plans.values()]
+
+    def find_run_by_batch(self, plan_uid, batch_key):
+        return deepcopy(self.runs.get((plan_uid, batch_key)))
+
+    def list_assets(self, source_uid):
+        return [
+            deepcopy(item)
+            for item in self.assets.values()
+            if item["source_uid"] == source_uid
+        ]
+
+    def get_asset(self, uid):
+        return deepcopy(self.assets.get(uid))
+
+    def apply_discovery(self, operation):
+        run = deepcopy(operation["run"])
+        self.runs[(run["plan_uid"], run["batch_key"])] = run
+        for asset in operation["assets"]:
+            self.assets[asset["uid"]] = deepcopy(asset)
+        self.versions.extend(deepcopy(operation["versions"]))
+        self.changes.extend(deepcopy(operation["changes"]))
+        self.lineage.extend(deepcopy(operation["lineage"]))
+        self.signals.extend(deepcopy(operation["health_signals"]))
+        self.plans[run["plan_uid"]]["cursor_state"] = deepcopy(run["cursor_after"])
+        return deepcopy(run)
+
+    def record_failed_run(self, run, signals):
+        self.runs[(run["plan_uid"], run["batch_key"])] = deepcopy(run)
+        self.signals.extend(deepcopy(signals))
+        return deepcopy(run)
+
+    def save_correction(self, correction, audit):
+        self.corrections[correction["uid"]] = deepcopy(correction)
+        self.audits.append(deepcopy(audit))
+        return deepcopy(correction)
+
+    def get_correction(self, uid):
+        return deepcopy(self.corrections.get(uid))
+
+    def resolve_correction(self, correction, audit):
+        self.corrections[correction["uid"]] = deepcopy(correction)
+        self.audits.append(deepcopy(audit))
+        return deepcopy(correction)
+
+
+def service(repository=None):
+    from app.core.meta_data.active_metadata import ActiveMetadataService
+
+    ids = (f"01900000-0000-7000-8000-{index:012d}" for index in range(301, 999))
+    return ActiveMetadataService(
+        repository or MemoryRepository(),
+        uid_factory=ids.__next__,
+        now_factory=lambda: datetime(2026, 7, 30, 9, 0, tzinfo=UTC),
+    )
+
+
+def create_plan(metadata, **overrides):
+    payload = {
+        "source_uid": SOURCE_UID,
+        "name": "客户与订单主动发现",
+        "source_kind": "database",
+        "schedule_type": "interval",
+        "schedule_expression": "PT30M",
+        "discovery_mode": "cursor",
+        "scope": {"schemas": ["public"]},
+        "owner_uid": OWNER_UID,
+    }
+    payload.update(overrides)
+    return metadata.create_plan(payload, actor_uid=ACTOR_UID)
+
+
+def test_discovery_plan_supports_database_file_and_api_schedules():
+    metadata = service()
+    database = create_plan(metadata)
+    file_plan = create_plan(
+        metadata,
+        source_kind="file",
+        name="受控文件主动发现",
+        schedule_type="cron",
+        schedule_expression="0 2 * * *",
+        discovery_mode="snapshot",
+    )
+    api_plan = create_plan(
+        metadata,
+        source_kind="api",
+        name="API Schema 主动发现",
+        schedule_type="manual",
+        schedule_expression=None,
+        discovery_mode="cursor",
+    )
+
+    assert database["source_kind"] == "database"
+    assert file_plan["source_kind"] == "file"
+    assert api_plan["source_kind"] == "api"
+    assert database["current_version"] == 1
+
+
+def test_repeated_batch_is_idempotent_and_incremental_diff_is_field_scoped():
+    repository = MemoryRepository()
+    metadata = service(repository)
+    plan = create_plan(metadata)
+
+    first = metadata.execute(
+        plan["uid"],
+        {
+            "snapshot": catalog_snapshot(),
+            "cursor_after": {"updated_at": "2026-07-30T08:00:00Z"},
+        },
+        batch_key="batch-001",
+        actor_uid=ACTOR_UID,
+    )
+    replay = metadata.execute(
+        plan["uid"],
+        {
+            "snapshot": catalog_snapshot(),
+            "cursor_after": {"updated_at": "2026-07-30T08:00:00Z"},
+        },
+        batch_key="batch-001",
+        actor_uid=ACTOR_UID,
+    )
+    second = metadata.execute(
+        plan["uid"],
+        {
+            "snapshot": catalog_snapshot(
+                include_email=True,
+                email_type="text",
+                include_orders=False,
+            ),
+            "cursor_after": {"updated_at": "2026-07-30T09:00:00Z"},
+        },
+        batch_key="batch-002",
+        actor_uid=ACTOR_UID,
+    )
+
+    assert first["statistics"]["asset_added"] == 2
+    assert replay == first
+    assert len(repository.runs) == 2
+    assert second["cursor_before"] == {"updated_at": "2026-07-30T08:00:00Z"}
+    changes = [item for item in repository.changes if item["run_uid"] == second["uid"]]
+    assert {
+        (item["change_type"], item["asset_key"], item.get("field_name"))
+        for item in changes
+    } == {
+        ("field_changed", f"{SOURCE_UID}:public.customers", "email"),
+        ("deletion_candidate", f"{SOURCE_UID}:public.orders", None),
+    }
+    orders = next(
+        item for item in repository.assets.values() if item["name"] == "orders"
+    )
+    assert orders["lifecycle_status"] == "deletion_candidate"
+
+
+def test_catalog_snapshot_runs_enabled_database_plans_for_its_source():
+    repository = MemoryRepository()
+    metadata = service(repository)
+    active = create_plan(metadata)
+    create_plan(metadata, name="停用计划", enabled=False)
+
+    runs = metadata.execute_source_plans(
+        SOURCE_UID,
+        catalog_snapshot(),
+        batch_key="catalog:snapshot-1",
+        actor_uid=ACTOR_UID,
+        cursor_after={"snapshot_uid": "snapshot-1"},
+    )
+    replayed = metadata.execute_source_plans(
+        SOURCE_UID,
+        catalog_snapshot(),
+        batch_key="catalog:snapshot-1",
+        actor_uid=ACTOR_UID,
+        cursor_after={"snapshot_uid": "snapshot-1"},
+    )
+
+    assert [item["plan_uid"] for item in runs] == [active["uid"]]
+    assert replayed == runs
+    assert len(repository.runs) == 1
+
+
+def test_sql_lineage_is_field_level_and_parse_failure_is_preserved():
+    from app.core.meta_data.active_metadata import parse_sql_field_lineage
+
+    parsed = parse_sql_field_lineage(
+        """
+        INSERT INTO mart.customer_summary (customer_id, customer_name)
+        SELECT c.id, c.name FROM public.customers c
+        """,
+        dialect="postgres",
+    )
+    failed = parse_sql_field_lineage(
+        "INSERT this is not valid SQL",
+        dialect="postgres",
+    )
+
+    assert parsed["status"] == "resolved"
+    assert {
+        (
+            item["source_asset"],
+            item["source_field"],
+            item["target_asset"],
+            item["target_field"],
+        )
+        for item in parsed["edges"]
+    } == {
+        ("public.customers", "id", "mart.customer_summary", "customer_id"),
+        ("public.customers", "name", "mart.customer_summary", "customer_name"),
+    }
+    assert failed["status"] == "failed"
+    assert failed["failure_reason"]
+    assert failed["edges"] == []
+
+
+def test_quality_freshness_failure_and_usage_are_written_as_health_signals():
+    repository = MemoryRepository()
+    metadata = service(repository)
+    plan = create_plan(metadata)
+    metadata.execute(
+        plan["uid"],
+        {
+            "snapshot": catalog_snapshot(include_orders=False),
+            "health_signals": [
+                {
+                    "asset_key": f"{SOURCE_UID}:public.customers",
+                    "signal_type": "quality",
+                    "value": 0.96,
+                    "status": "healthy",
+                },
+                {
+                    "asset_key": f"{SOURCE_UID}:public.customers",
+                    "signal_type": "freshness",
+                    "value": 1800,
+                    "status": "healthy",
+                },
+                {
+                    "asset_key": f"{SOURCE_UID}:public.customers",
+                    "signal_type": "usage",
+                    "value": 42,
+                    "status": "healthy",
+                },
+            ],
+        },
+        batch_key="health-001",
+        actor_uid=ACTOR_UID,
+    )
+    metadata.record_failure(
+        plan["uid"],
+        batch_key="health-002",
+        error_code="COLLECT_TIMEOUT",
+        failure_reason="catalog collector timed out",
+        actor_uid=ACTOR_UID,
+    )
+
+    assert {item["signal_type"] for item in repository.signals} == {
+        "quality",
+        "freshness",
+        "usage",
+        "task_failure",
+    }
+    failed = repository.runs[(plan["uid"], "health-002")]
+    assert failed["status"] == "failed"
+    assert failed["failure_reason"] == "catalog collector timed out"
+
+
+def test_user_correction_requires_owner_resolution_and_keeps_version_audit():
+    repository = MemoryRepository()
+    metadata = service(repository)
+    plan = create_plan(metadata)
+    run = metadata.execute(
+        plan["uid"],
+        {"snapshot": catalog_snapshot(include_orders=False)},
+        batch_key="correction-001",
+        actor_uid=ACTOR_UID,
+    )
+    asset_uid = next(iter(repository.assets))
+    correction = metadata.submit_correction(
+        asset_uid,
+        {
+            "field_name": "email",
+            "proposed_value": {"comment": "客户首选联系邮箱"},
+            "reason": "补充字段业务说明",
+            "assignee_uid": OWNER_UID,
+        },
+        actor_uid=ACTOR_UID,
+    )
+
+    with pytest.raises(ValueError, match="assignee"):
+        metadata.resolve_correction(
+            correction["uid"],
+            expected_version=1,
+            decision="accept",
+            resolution={"comment": "客户首选联系邮箱"},
+            actor_uid=ACTOR_UID,
+        )
+
+    resolved = metadata.resolve_correction(
+        correction["uid"],
+        expected_version=1,
+        decision="accept",
+        resolution={"comment": "客户首选联系邮箱"},
+        actor_uid=OWNER_UID,
+    )
+
+    assert run["status"] == "completed"
+    assert resolved["status"] == "resolved"
+    assert resolved["current_version"] == 2
+    assert [item["action"] for item in repository.audits] == [
+        "correction_submitted",
+        "correction_resolved",
+    ]
+
+
+@pytest.mark.parametrize(
+    "overrides",
+    [
+        {"source_kind": "kafka"},
+        {"schedule_type": "interval", "schedule_expression": "every hour"},
+        {"schedule_type": "cron", "schedule_expression": "* *"},
+        {"discovery_mode": "overwrite"},
+        {"scope": {"password": "secret"}},
+    ],
+)
+def test_plan_validation_rejects_unsafe_or_unsupported_values(overrides):
+    with pytest.raises(ValueError):
+        create_plan(service(), **overrides)

+ 32 - 0
tests/core/data_source/test_active_metadata_frontend_contract.py

@@ -0,0 +1,32 @@
+from pathlib import Path
+
+ROOT = Path(__file__).parents[3]
+
+
+def test_active_metadata_frontend_exposes_readable_operations_console():
+    api = (ROOT / "frontend/src/api/activeMetadata.js").read_text()
+    page = (
+        ROOT / "frontend/src/views/dataGovernance/development/activeMetadata.vue"
+    ).read_text()
+    routes = (ROOT / "frontend/src/router/routes.js").read_text()
+    center = (
+        ROOT / "frontend/src/views/dataGovernance/development/index.vue"
+    ).read_text()
+
+    assert "/meta/active-metadata/plans" in api
+    assert "/assets" in api
+    assert "/changes" in api
+    assert "/lineage" in api
+    assert "/health" in api
+    assert "/corrections" in api
+
+    assert "主动元数据与血缘" in page
+    assert "删除候选" in page
+    assert "解析失败原因" in page
+    assert "健康信号" in page
+    assert "纠错审计" in page
+    assert "isAdmin" in page
+
+    assert "/data-governance/development/active-metadata" in routes
+    assert "dataGovernance/development/activeMetadata" in routes
+    assert "/data-governance/development/active-metadata" in center

+ 25 - 0
tests/data_research/test_catalog_execution.py

@@ -145,6 +145,31 @@ def test_catalog_execution_persists_snapshot_evidence_and_job_report():
     assert len(collector.calls) == 1
 
 
+def test_catalog_execution_projects_new_snapshot_into_active_metadata_once():
+    from app.core.data_research.catalog.execution import CatalogIngestionExecutor
+    from app.core.data_research.ingestion import IngestionService
+
+    jobs = MemoryJobRepository()
+    ingestion = IngestionService(jobs)
+    job, _ = ingestion.create_job(catalog_payload(), actor_uid="editor-1")
+    snapshots = MemorySnapshotRepository()
+    projections = []
+    executor = CatalogIngestionExecutor(
+        ingestion,
+        CatalogCollector(),
+        snapshots,
+        on_snapshot=lambda current_job, record: projections.append(
+            (current_job.uid, record.uid)
+        ),
+    )
+
+    completed = executor.execute(job.uid)
+    replayed = executor.execute(job.uid)
+
+    assert replayed == completed
+    assert projections == [(job.uid, "snapshot-1")]
+
+
 def test_failed_catalog_collection_records_stage_and_can_retry():
     from app.core.data_research.catalog.execution import CatalogIngestionExecutor
     from app.core.data_research.ingestion import IngestionService

+ 320 - 0
tests/integration/test_active_metadata_postgres.py

@@ -0,0 +1,320 @@
+from __future__ import annotations
+
+import os
+import uuid
+
+import pytest
+from sqlalchemy import text
+
+pytestmark = pytest.mark.integration
+
+
+def test_active_metadata_is_idempotent_traceable_and_correctable(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.meta_data.active_metadata import ActiveMetadataService
+    from app.core.meta_data.active_metadata_repository import (
+        SqlAlchemyActiveMetadataRepository,
+    )
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    actor_uid = str(uuid.uuid4())
+    owner_uid = str(uuid.uuid4())
+    source_uid = str(uuid.uuid4())
+    plan_uid = None
+    try:
+        with app.app_context():
+            for uid, name in ((actor_uid, "actor"), (owner_uid, "owner")):
+                db.session.execute(
+                    text(
+                        """
+                        INSERT INTO public.users (
+                            id, username, display_name, password_hash, status
+                        ) VALUES (
+                            CAST(:id AS uuid), :username, :username,
+                            'p2-wp02-integration-hash', 'active'
+                        )
+                        """
+                    ),
+                    {"id": uid, "username": f"wp02-{name}-{uid[:8]}"},
+                )
+            db.session.execute(
+                text(
+                    """
+                    INSERT INTO public.ingestion_sources (
+                        uid, source_type, name, config, permission_scope,
+                        status, created_by
+                    ) VALUES (
+                        CAST(:uid AS uuid), 'database', :name,
+                        '{}'::jsonb, '{}'::jsonb, 'active', :created_by
+                    )
+                    """
+                ),
+                {
+                    "uid": source_uid,
+                    "name": f"WP02 source {source_uid[:8]}",
+                    "created_by": actor_uid,
+                },
+            )
+            db.session.commit()
+
+            repository = SqlAlchemyActiveMetadataRepository(db.session)
+            service = ActiveMetadataService(repository)
+            plan = service.create_plan(
+                {
+                    "source_uid": source_uid,
+                    "name": "WP02 集成主动发现",
+                    "source_kind": "database",
+                    "schedule_type": "interval",
+                    "schedule_expression": "PT30M",
+                    "discovery_mode": "cursor",
+                    "scope": {"schemas": ["public"]},
+                    "owner_uid": owner_uid,
+                },
+                actor_uid=actor_uid,
+            )
+            db.session.commit()
+            plan_uid = plan["uid"]
+
+            first_payload = {
+                "snapshot": {
+                    "assets": [
+                        {
+                            "name": "customers",
+                            "namespace": "public",
+                            "asset_type": "table",
+                            "fields": [
+                                {
+                                    "name": "id",
+                                    "data_type": "bigint",
+                                    "nullable": False,
+                                    "ordinal_position": 1,
+                                },
+                                {
+                                    "name": "email",
+                                    "data_type": "varchar",
+                                    "nullable": True,
+                                    "ordinal_position": 2,
+                                },
+                            ],
+                        },
+                        {
+                            "name": "orders",
+                            "namespace": "public",
+                            "asset_type": "table",
+                            "fields": [],
+                        },
+                    ]
+                },
+                "cursor_after": {"catalog_version": 1},
+                "lineage_sql": [
+                    {
+                        "dialect": "postgres",
+                        "sql": (
+                            "INSERT INTO mart.customer_email (customer_id, email) "
+                            "SELECT c.id, c.email FROM public.customers c"
+                        ),
+                    },
+                    "not valid lineage SQL",
+                ],
+                "health_signals": [
+                    {
+                        "asset_key": f"{source_uid}:public.customers",
+                        "signal_type": "quality",
+                        "value": 0.98,
+                        "status": "healthy",
+                    },
+                    {
+                        "asset_key": f"{source_uid}:public.customers",
+                        "signal_type": "freshness",
+                        "value": 60,
+                        "status": "healthy",
+                    },
+                    {
+                        "asset_key": f"{source_uid}:public.customers",
+                        "signal_type": "usage",
+                        "value": 12,
+                        "status": "healthy",
+                    },
+                ],
+            }
+            first = service.execute_source_plans(
+                source_uid,
+                first_payload["snapshot"],
+                batch_key="wp02-batch-1",
+                actor_uid=actor_uid,
+                cursor_after=first_payload["cursor_after"],
+            )[0]
+            # SQL lineage and health use the same batch contract as the
+            # automatic snapshot projection and are appended by the runner.
+            first = service.execute(
+                plan_uid,
+                first_payload,
+                batch_key="wp02-batch-1-enriched",
+                actor_uid=actor_uid,
+            )
+            db.session.commit()
+            replay = service.execute(
+                plan_uid,
+                first_payload,
+                batch_key="wp02-batch-1-enriched",
+                actor_uid=actor_uid,
+            )
+            db.session.commit()
+            assert replay["uid"] == first["uid"]
+
+            second_payload = {
+                "snapshot": {
+                    "assets": [
+                        {
+                            "name": "customers",
+                            "namespace": "public",
+                            "asset_type": "table",
+                            "fields": [
+                                {
+                                    "name": "id",
+                                    "data_type": "bigint",
+                                    "nullable": False,
+                                    "ordinal_position": 1,
+                                },
+                                {
+                                    "name": "email",
+                                    "data_type": "text",
+                                    "nullable": True,
+                                    "ordinal_position": 2,
+                                },
+                            ],
+                        }
+                    ]
+                },
+                "cursor_after": {"catalog_version": 2},
+            }
+            second = service.execute(
+                plan_uid,
+                second_payload,
+                batch_key="wp02-batch-2",
+                actor_uid=actor_uid,
+            )
+            db.session.commit()
+            assert second["cursor_before"] == {"catalog_version": 1}
+
+            assets = service.list_assets(source_uid)
+            customer = next(item for item in assets if item["name"] == "customers")
+            orders = next(item for item in assets if item["name"] == "orders")
+            assert customer["current_version"] == 2
+            assert orders["lifecycle_status"] == "deletion_candidate"
+
+            changes = service.list_changes(second["uid"])
+            assert {(item["change_type"], item["field_name"]) for item in changes} == {
+                ("field_changed", "email"),
+                ("deletion_candidate", None),
+            }
+            lineage = service.list_lineage(first["uid"])
+            assert sum(item["parse_status"] == "resolved" for item in lineage) == 2
+            assert sum(item["parse_status"] == "failed" for item in lineage) == 1
+
+            failed = service.record_failure(
+                plan_uid,
+                batch_key="wp02-batch-3",
+                error_code="TIMEOUT",
+                failure_reason="collector timed out",
+                actor_uid=actor_uid,
+            )
+            db.session.commit()
+            assert failed["status"] == "failed"
+
+            correction = service.submit_correction(
+                customer["uid"],
+                {
+                    "field_name": "email",
+                    "proposed_value": {"comment": "客户邮箱"},
+                    "reason": "补充业务定义",
+                    "assignee_uid": owner_uid,
+                },
+                actor_uid=actor_uid,
+            )
+            db.session.commit()
+            resolved = service.resolve_correction(
+                correction["uid"],
+                expected_version=1,
+                decision="accept",
+                resolution={"comment": "客户邮箱"},
+                actor_uid=owner_uid,
+            )
+            db.session.commit()
+            assert resolved["current_version"] == 2
+
+            counts = db.session.execute(
+                text(
+                    """
+                    SELECT
+                      (SELECT count(*) FROM public.active_metadata_runs
+                       WHERE plan_uid = CAST(:plan_uid AS uuid)) AS runs,
+                      (SELECT count(*) FROM public.active_metadata_asset_versions v
+                       JOIN public.active_metadata_assets a ON a.uid = v.asset_uid
+                       WHERE a.source_uid = CAST(:source_uid AS uuid)) AS versions,
+                      (SELECT count(*) FROM public.active_metadata_correction_audits ca
+                       JOIN public.active_metadata_corrections c
+                         ON c.uid = ca.correction_uid
+                       JOIN public.active_metadata_assets a ON a.uid = c.asset_uid
+                       WHERE a.source_uid = CAST(:source_uid AS uuid)) AS audits
+                    """
+                ),
+                {"plan_uid": plan_uid, "source_uid": source_uid},
+            ).mappings().one()
+            assert dict(counts) == {"runs": 4, "versions": 3, "audits": 2}
+    finally:
+        with app.app_context():
+            db.session.rollback()
+            if plan_uid:
+                for statement in (
+                    "DELETE FROM public.active_metadata_correction_audits "
+                    "WHERE correction_uid IN (SELECT c.uid FROM public.active_metadata_corrections c "
+                    "JOIN public.active_metadata_assets a ON a.uid = c.asset_uid "
+                    "WHERE a.source_uid = CAST(:source_uid AS uuid))",
+                    "DELETE FROM public.active_metadata_corrections "
+                    "WHERE asset_uid IN (SELECT uid FROM public.active_metadata_assets "
+                    "WHERE source_uid = CAST(:source_uid AS uuid))",
+                    "DELETE FROM public.active_metadata_health_signals "
+                    "WHERE asset_uid IN (SELECT uid FROM public.active_metadata_assets "
+                    "WHERE source_uid = CAST(:source_uid AS uuid))",
+                    "DELETE FROM public.active_metadata_lineage "
+                    "WHERE run_uid IN (SELECT uid FROM public.active_metadata_runs "
+                    "WHERE plan_uid = CAST(:plan_uid AS uuid))",
+                    "DELETE FROM public.active_metadata_changes "
+                    "WHERE run_uid IN (SELECT uid FROM public.active_metadata_runs "
+                    "WHERE plan_uid = CAST(:plan_uid AS uuid))",
+                    "DELETE FROM public.active_metadata_asset_versions "
+                    "WHERE asset_uid IN (SELECT uid FROM public.active_metadata_assets "
+                    "WHERE source_uid = CAST(:source_uid AS uuid))",
+                    "DELETE FROM public.active_metadata_assets "
+                    "WHERE source_uid = CAST(:source_uid AS uuid)",
+                    "DELETE FROM public.active_metadata_runs "
+                    "WHERE plan_uid = CAST(:plan_uid AS uuid)",
+                    "DELETE FROM public.active_metadata_plans "
+                    "WHERE uid = CAST(:plan_uid AS uuid)",
+                ):
+                    db.session.execute(
+                        text(statement),
+                        {"plan_uid": plan_uid, "source_uid": source_uid},
+                    )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.ingestion_sources "
+                    "WHERE uid = CAST(:source_uid AS uuid)"
+                ),
+                {"source_uid": source_uid},
+            )
+            db.session.execute(
+                text(
+                    "DELETE FROM public.users "
+                    "WHERE id IN (CAST(:actor_uid AS uuid), CAST(:owner_uid AS uuid))"
+                ),
+                {"actor_uid": actor_uid, "owner_uid": owner_uid},
+            )
+            db.session.commit()

+ 128 - 0
tests/test_active_metadata_api.py

@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+ACTOR_UID = "01900000-0000-7000-8000-000000000101"
+PLAN_UID = "01900000-0000-7000-8000-000000000201"
+ASSET_UID = "01900000-0000-7000-8000-000000000301"
+CORRECTION_UID = "01900000-0000-7000-8000-000000000401"
+
+
+class FakeService:
+    def __init__(self):
+        self.calls = []
+
+    def list_plans(self):
+        return [{"uid": PLAN_UID, "name": "主动发现"}]
+
+    def create_plan(self, payload, *, actor_uid):
+        self.calls.append(("create_plan", payload, actor_uid))
+        return {"uid": PLAN_UID, **payload}
+
+    def execute(self, plan_uid, payload, *, batch_key, actor_uid):
+        self.calls.append(("execute", plan_uid, batch_key, actor_uid))
+        return {"uid": "run-1", "status": "completed"}
+
+    def list_assets(self, source_uid):
+        return [{"uid": ASSET_UID, "source_uid": source_uid}]
+
+    def list_runs(self, plan_uid):
+        return [{"uid": "run-1", "plan_uid": plan_uid}]
+
+    def list_changes(self, run_uid):
+        return [{"run_uid": run_uid, "change_type": "field_changed"}]
+
+    def list_lineage(self, run_uid):
+        return [{"run_uid": run_uid, "parse_status": "resolved"}]
+
+    def list_health_signals(self, run_uid):
+        return [{"run_uid": run_uid, "signal_type": "quality"}]
+
+    def list_corrections(self, asset_uid=None):
+        return [{"uid": CORRECTION_UID, "asset_uid": asset_uid}]
+
+    def submit_correction(self, asset_uid, payload, *, actor_uid):
+        return {"uid": CORRECTION_UID, "asset_uid": asset_uid, "status": "pending"}
+
+    def resolve_correction(self, correction_uid, **kwargs):
+        self.calls.append(("resolve", correction_uid, kwargs))
+        return {"uid": correction_uid, "status": "resolved", "current_version": 2}
+
+
+def _headers(role, **extra):
+    return {"Authorization": f"Bearer {role}", **extra}
+
+
+def test_active_metadata_read_operate_and_admin_permissions(monkeypatch):
+    from app import create_app
+    from app.api.meta_data import active_metadata
+
+    service = FakeService()
+    monkeypatch.setattr(active_metadata, "_service", lambda: service)
+    monkeypatch.setattr(
+        "app.core.system.auth.load_identity_from_token",
+        lambda token, secret: (
+            {"id": ACTOR_UID, "username": token, "roles": [token]}
+            if token in {"viewer", "editor", "admin"}
+            else None
+        ),
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    client = app.test_client()
+
+    assert client.get(
+        "/api/meta/active-metadata/plans", headers=_headers("viewer")
+    ).status_code == 200
+    assert client.post(
+        "/api/meta/active-metadata/plans",
+        json={"name": "plan"},
+        headers=_headers("editor"),
+    ).status_code == 403
+    assert client.post(
+        "/api/meta/active-metadata/plans",
+        json={"name": "plan"},
+        headers=_headers("admin"),
+    ).status_code == 201
+    assert client.post(
+        f"/api/meta/active-metadata/plans/{PLAN_UID}/runs",
+        json={"snapshot": {"assets": []}},
+        headers=_headers("viewer", **{"Idempotency-Key": "batch-1"}),
+    ).status_code == 403
+    executed = client.post(
+        f"/api/meta/active-metadata/plans/{PLAN_UID}/runs",
+        json={"snapshot": {"assets": []}},
+        headers=_headers("editor", **{"Idempotency-Key": "batch-1"}),
+    )
+    assert executed.status_code == 200
+    assert service.calls[-1] == ("execute", PLAN_UID, "batch-1", ACTOR_UID)
+
+    resolved = client.post(
+        f"/api/meta/active-metadata/corrections/{CORRECTION_UID}/resolve",
+        json={
+            "expected_version": 1,
+            "decision": "accept",
+            "resolution": {"comment": "accepted"},
+        },
+        headers=_headers("editor"),
+    )
+    assert resolved.status_code == 200
+
+
+def test_active_metadata_paths_have_dedicated_policy():
+    from app.core.system.permissions import (
+        ACTIVE_METADATA_MANAGE,
+        ACTIVE_METADATA_OPERATE,
+        ACTIVE_METADATA_READ,
+        permission_for_request,
+    )
+
+    base = "/api/meta/active-metadata"
+    assert permission_for_request(f"{base}/plans", "GET") == (ACTIVE_METADATA_READ,)
+    assert permission_for_request(f"{base}/plans", "POST") == (
+        ACTIVE_METADATA_MANAGE,
+    )
+    assert permission_for_request(f"{base}/plans/{PLAN_UID}/runs", "POST") == (
+        ACTIVE_METADATA_OPERATE,
+    )
+    assert permission_for_request(f"{base}/runs/run-1/lineage", "GET") == (
+        ACTIVE_METADATA_READ,
+    )

+ 30 - 0
tests/test_phase2_wp02_migration_contract.py

@@ -0,0 +1,30 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_active_metadata_migration_is_additive_traceable_and_non_destructive():
+    migration = (
+        ROOT / "migrations/versions/20260730_380_active_metadata_lineage.py"
+    ).read_text(encoding="utf-8")
+
+    assert 'revision = "20260730_380"' in migration
+    assert 'down_revision = "20260730_370"' in migration
+    for table in (
+        "active_metadata_plans",
+        "active_metadata_runs",
+        "active_metadata_assets",
+        "active_metadata_asset_versions",
+        "active_metadata_changes",
+        "active_metadata_lineage",
+        "active_metadata_health_signals",
+        "active_metadata_corrections",
+        "active_metadata_correction_audits",
+    ):
+        assert f"CREATE TABLE public.{table}" in migration
+    assert "UNIQUE (plan_uid, batch_key)" in migration
+    assert "deletion_candidate" in migration
+    assert "source_field" in migration and "target_field" in migration
+    assert "failure_reason" in migration
+    assert "DROP TABLE" not in migration.upper()
+    assert "DELETE FROM public" not in migration