Browse Source

feat: validate second domain replication

马小龙 2 weeks ago
parent
commit
5267d8b101

+ 528 - 0
app/core/governance/domain_replication.py

@@ -0,0 +1,528 @@
+"""Generic, evidence-bound acceptance for copying a governance domain.
+
+The evaluator consumes a self-contained implementation package.  It validates
+controlled snapshot/delta ingestion and canonical receipts from existing
+platform modules; it does not execute domain-specific business logic or store
+source rows in the report.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from copy import deepcopy
+from typing import Any
+
+REQUIRED_STAGES = (
+    "template_initialization",
+    "incremental_ingestion",
+    "catalog",
+    "semantics",
+    "responsibility",
+    "quality",
+    "remediation",
+    "observability",
+    "data_product",
+    "agent",
+)
+
+STAGE_SUBSYSTEMS = {
+    "template_initialization": "domain_templates",
+    "incremental_ingestion": "active_metadata",
+    "catalog": "active_metadata",
+    "semantics": "semantic_governance",
+    "responsibility": "unified_responsibilities",
+    "quality": "quality_operations",
+    "remediation": "unified_work_center",
+    "observability": "data_observability",
+    "data_product": "product_governance",
+    "agent": "agent_governance",
+}
+
+_CODE = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
+_SECRET = re.compile(
+    r"(?:password|passwd|secret|token|credential|authorization|api[_-]?key)",
+    re.IGNORECASE,
+)
+_DEVICE_PATH_MARKERS = (
+    "app/core/data_research/device_",
+    "app/api/data_development/device_",
+    "frontend/src/views/dataResearch/device",
+)
+_REQUIRED_ENTERPRISE_BINDINGS = {
+    "data_steward",
+    "domain_owner",
+    "readonly_source",
+    "uat_users",
+}
+
+
+class DomainReplicationError(ValueError):
+    """Raised when an implementation package cannot prove a replication gate."""
+
+
+def _canonical(value: Any) -> bytes:
+    return json.dumps(
+        value,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    ).encode("utf-8")
+
+
+def _digest(value: Any) -> str:
+    return hashlib.sha256(_canonical(value)).hexdigest()
+
+
+def _mapping(value: Any, field: str) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise DomainReplicationError(f"{field} must be an object")
+    return value
+
+
+def _bounded_text(value: Any, field: str, limit: int = 256) -> str:
+    rendered = str(value or "").strip()
+    if not rendered or len(rendered) > limit:
+        raise DomainReplicationError(f"{field} must be non-empty and <= {limit}")
+    return rendered
+
+
+def _reject_secrets(value: Any, path: str = "$") -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if _SECRET.search(str(key)):
+                raise DomainReplicationError(
+                    f"secret material is not allowed at {path}.{key}"
+                )
+            _reject_secrets(item, f"{path}.{key}")
+    elif isinstance(value, list):
+        for index, item in enumerate(value):
+            _reject_secrets(item, f"{path}[{index}]")
+
+
+def _row_key(row: dict[str, str], fields: list[str]) -> tuple[str, ...]:
+    values = tuple(str(row.get(field, "")).strip() for field in fields)
+    if any(not value for value in values):
+        raise DomainReplicationError("source primary key values must be non-empty")
+    return values
+
+
+def _normalized_row(
+    row: dict[str, str], *, operation_field: str, cursor_field: str
+) -> dict[str, str]:
+    return {
+        str(key): str(value or "").strip()
+        for key, value in sorted(row.items())
+        if key not in {operation_field, cursor_field}
+    }
+
+
+def _validate_source_fields(rows: list[dict[str, str]]) -> None:
+    for row in rows:
+        if not isinstance(row, dict):
+            raise DomainReplicationError("source rows must be objects")
+        for field in row:
+            if _SECRET.search(str(field)):
+                raise DomainReplicationError(
+                    f"secret-like source field is not allowed: {field}"
+                )
+
+
+def collect_incremental_rows(
+    snapshot_rows: list[dict[str, str]],
+    delta_rows: list[dict[str, str]],
+    source: dict[str, Any],
+) -> dict[str, Any]:
+    """Apply a controlled snapshot and monotonic delta without exposing rows."""
+
+    source = _mapping(source, "source")
+    if source.get("kind") != "controlled_csv":
+        raise DomainReplicationError("source.kind must be controlled_csv")
+    if source.get("classification") != "desensitized":
+        raise DomainReplicationError("source.classification must be desensitized")
+    primary_key = source.get("primary_key")
+    if not isinstance(primary_key, list) or not primary_key:
+        raise DomainReplicationError("source.primary_key must be a non-empty list")
+    primary_key = [_bounded_text(item, "source.primary_key") for item in primary_key]
+    operation_field = _bounded_text(
+        source.get("operation_field", "_operation"), "source.operation_field"
+    )
+    cursor_field = _bounded_text(
+        source.get("cursor_field", "_cursor"), "source.cursor_field"
+    )
+    required_columns = source.get("required_columns")
+    if not isinstance(required_columns, list) or not required_columns:
+        raise DomainReplicationError("source.required_columns must be a non-empty list")
+    required_columns = {
+        _bounded_text(item, "source.required_columns") for item in required_columns
+    }
+    cursor_before = int(source.get("snapshot_cursor", -1))
+    if cursor_before < 0:
+        raise DomainReplicationError("source.snapshot_cursor must be non-negative")
+
+    if not snapshot_rows:
+        raise DomainReplicationError("controlled snapshot must contain rows")
+    if not delta_rows:
+        raise DomainReplicationError("controlled delta must contain rows")
+    _validate_source_fields(snapshot_rows)
+    _validate_source_fields(delta_rows)
+
+    state: dict[tuple[str, ...], dict[str, str]] = {}
+    for row in snapshot_rows:
+        if not required_columns.issubset(row):
+            missing = sorted(required_columns - set(row))
+            raise DomainReplicationError(f"snapshot is missing required columns: {missing}")
+        key = _row_key(row, primary_key)
+        if key in state:
+            raise DomainReplicationError("snapshot primary keys must be unique")
+        state[key] = _normalized_row(
+            row, operation_field=operation_field, cursor_field=cursor_field
+        )
+
+    cursors: list[int] = []
+    for row in delta_rows:
+        if cursor_field not in row or operation_field not in row:
+            raise DomainReplicationError("delta cursor and operation fields are required")
+        try:
+            cursor = int(row[cursor_field])
+        except (TypeError, ValueError) as exc:
+            raise DomainReplicationError("delta cursor must be an integer") from exc
+        if cursor <= cursor_before or (cursors and cursor <= cursors[-1]):
+            raise DomainReplicationError("delta cursors must strictly increase")
+        cursors.append(cursor)
+
+    def apply_delta() -> dict[str, int]:
+        result = {"inserted": 0, "updated": 0, "deleted": 0}
+        for row in delta_rows:
+            operation = str(row[operation_field]).strip().lower()
+            if operation not in {"upsert", "delete"}:
+                raise DomainReplicationError(f"unsupported delta operation: {operation}")
+            key = _row_key(row, primary_key)
+            if operation == "delete":
+                if key in state:
+                    del state[key]
+                    result["deleted"] += 1
+                continue
+            if not required_columns.issubset(row):
+                missing = sorted(required_columns - set(row))
+                raise DomainReplicationError(f"delta is missing required columns: {missing}")
+            normalized = _normalized_row(
+                row, operation_field=operation_field, cursor_field=cursor_field
+            )
+            if key not in state:
+                state[key] = normalized
+                result["inserted"] += 1
+            elif state[key] != normalized:
+                state[key] = normalized
+                result["updated"] += 1
+        return result
+
+    first = apply_delta()
+    first_state = deepcopy(state)
+    replay = apply_delta()
+    if state != first_state:
+        raise DomainReplicationError("delta replay changed final source state")
+    replay_changes = sum(replay.values())
+    final_rows = [state[key] for key in sorted(state)]
+    return {
+        "source_kind": "controlled_csv",
+        "classification": "desensitized",
+        "snapshot_rows": len(snapshot_rows),
+        "delta_rows": len(delta_rows),
+        **first,
+        "final_rows": len(state),
+        "replay_changes": replay_changes,
+        "cursor_before": cursor_before,
+        "cursor_after": cursors[-1],
+        "snapshot_digest": _digest(
+            sorted(
+                (
+                    _normalized_row(
+                        row,
+                        operation_field=operation_field,
+                        cursor_field=cursor_field,
+                    )
+                    for row in snapshot_rows
+                ),
+                key=_canonical,
+            )
+        ),
+        "delta_digest": _digest(
+            [
+                {
+                    **_normalized_row(
+                        row,
+                        operation_field=operation_field,
+                        cursor_field=cursor_field,
+                    ),
+                    operation_field: row[operation_field],
+                    cursor_field: int(row[cursor_field]),
+                }
+                for row in delta_rows
+            ]
+        ),
+        "final_digest": _digest(final_rows),
+    }
+
+
+def _positive(metrics: dict[str, Any], field: str, minimum: float = 1) -> float:
+    try:
+        value = float(metrics.get(field, 0))
+    except (TypeError, ValueError) as exc:
+        raise DomainReplicationError(f"{field} must be numeric") from exc
+    if value < minimum:
+        raise DomainReplicationError(f"{field} must be >= {minimum:g}")
+    return value
+
+
+def _validate_stage_metrics(
+    stage: str,
+    metrics: dict[str, Any],
+    *,
+    template: dict[str, Any],
+    incremental: dict[str, Any],
+) -> None:
+    if stage == "template_initialization":
+        expected = {
+            "object_type_count": len(template.get("object_types", [])),
+            "rule_count": len(template.get("rules", [])),
+            "role_count": len(template.get("responsibility_roles", [])),
+            "metric_count": len(template.get("metrics", [])),
+        }
+        if any(int(metrics.get(key, -1)) != value for key, value in expected.items()):
+            raise DomainReplicationError("template initialization counts do not match template")
+        _positive(metrics, "template_version")
+    elif stage == "incremental_ingestion":
+        for key in (
+            "snapshot_rows",
+            "delta_rows",
+            "inserted",
+            "updated",
+            "deleted",
+            "final_rows",
+            "replay_changes",
+            "cursor_before",
+            "cursor_after",
+        ):
+            if int(metrics.get(key, -1)) != int(incremental[key]):
+                raise DomainReplicationError(
+                    f"incremental ingestion evidence does not match collected {key}"
+                )
+    elif stage == "catalog":
+        _positive(metrics, "asset_count", 3)
+        _positive(metrics, "incremental_change_count")
+        if int(metrics.get("cursor_after", -1)) != incremental["cursor_after"]:
+            raise DomainReplicationError("catalog cursor does not match ingestion cursor")
+    elif stage == "semantics":
+        for field in (
+            "published_term_count",
+            "published_code_set_count",
+            "published_metric_count",
+            "mapped_field_count",
+        ):
+            _positive(metrics, field)
+    elif stage == "responsibility":
+        if float(metrics.get("coverage_percent", 0)) != 100:
+            raise DomainReplicationError("responsibility coverage must be 100 percent")
+        _positive(metrics, "bound_role_count", 3)
+    elif stage == "quality":
+        _positive(metrics, "published_rule_count", 5)
+        _positive(metrics, "initial_finding_count")
+        target = float(metrics.get("target_score", 0))
+        final = float(metrics.get("final_score", 0))
+        if target <= 0 or final < target:
+            raise DomainReplicationError("quality final score must meet target score")
+    elif stage == "remediation":
+        expected = {
+            "issue_status": "closed",
+            "task_status": "completed",
+            "independent_closer": True,
+        }
+        if metrics != expected:
+            raise DomainReplicationError("remediation issue and task must be independently closed")
+    elif stage == "observability":
+        _positive(metrics, "slo_count")
+        if metrics.get("incident_status") != "closed" or metrics.get("recovered") is not True:
+            raise DomainReplicationError("observability incident must recover and close")
+    elif stage == "data_product":
+        if metrics.get("contract_status") != "active":
+            raise DomainReplicationError("data product contract must be active")
+        if metrics.get("certificate_status") != "issued":
+            raise DomainReplicationError("data product certificate must be issued")
+        refs = metrics.get("certificate_evidence_refs")
+        if not isinstance(refs, list) or len(refs) < 4:
+            raise DomainReplicationError("certificate evidence must bind quality, lineage, rule and workflow")
+        required = ("quality://", "lineage://", "rule://", "workflow://")
+        if not all(any(str(ref).startswith(prefix) for ref in refs) for prefix in required):
+            raise DomainReplicationError("certificate evidence is missing canonical references")
+    elif stage == "agent":
+        if metrics.get("autonomy_level") not in {"read_only", "suggestion"}:
+            raise DomainReplicationError("agent autonomy must be read_only or suggestion")
+        if metrics.get("decision") != "authorized":
+            raise DomainReplicationError("agent action must be authorized")
+        if metrics.get("automatic_execution_allowed") is not False:
+            raise DomainReplicationError("agent automatic execution must remain disabled")
+        _positive(metrics, "citation_count")
+        if metrics.get("cross_domain_denied") is not True:
+            raise DomainReplicationError("agent cross-domain request must be denied")
+
+
+def _validate_manifest(manifest: dict[str, Any], template: dict[str, Any]) -> tuple[str, str]:
+    manifest = _mapping(manifest, "manifest")
+    _reject_secrets(manifest)
+    if int(manifest.get("schema_version", 0)) != 1:
+        raise DomainReplicationError("manifest.schema_version must be 1")
+    package_code = _bounded_text(manifest.get("package_code"), "package_code", 80)
+    domain = _mapping(manifest.get("domain"), "domain")
+    domain_code = _bounded_text(domain.get("code"), "domain.code", 64)
+    if not _CODE.fullmatch(domain_code):
+        raise DomainReplicationError("domain.code must be a stable lowercase code")
+    if template.get("template_code") != domain_code:
+        raise DomainReplicationError("domain template code does not match package domain")
+    if len(template.get("object_types", [])) < 3:
+        raise DomainReplicationError("domain template must contain at least three object types")
+    if len(template.get("rules", [])) < 5:
+        raise DomainReplicationError("domain template must contain at least five rules")
+
+    assessment = _mapping(
+        manifest.get("core_change_assessment"), "core_change_assessment"
+    )
+    device_changes = assessment.get("device_specific_changes")
+    if not isinstance(device_changes, list):
+        raise DomainReplicationError("device_specific_changes must be a list")
+    if device_changes:
+        raise DomainReplicationError("device-specific core changes must remain zero")
+    generic_extensions = assessment.get("generic_extensions")
+    if not isinstance(generic_extensions, list) or not generic_extensions:
+        raise DomainReplicationError("generic extension points must be documented")
+    if any(
+        str(path).startswith(marker)
+        for path in generic_extensions
+        for marker in _DEVICE_PATH_MARKERS
+    ):
+        raise DomainReplicationError("generic extensions cannot target device-specific paths")
+
+    reuse = _mapping(manifest.get("third_domain_reuse"), "third_domain_reuse")
+    if reuse.get("reusable") is not True:
+        raise DomainReplicationError("third-domain reuse must be explicitly enabled")
+    placeholders = reuse.get("required_replacements")
+    if not isinstance(placeholders, list) or len(placeholders) < 4:
+        raise DomainReplicationError("third-domain replacement checklist is incomplete")
+    return package_code, domain_code
+
+
+def evaluate_replication_package(
+    manifest: dict[str, Any],
+    evidence: dict[str, Any],
+    *,
+    template: dict[str, Any],
+    snapshot_rows: list[dict[str, str]],
+    delta_rows: list[dict[str, str]],
+) -> dict[str, Any]:
+    """Evaluate one domain package and return a deterministic, row-free report."""
+
+    package_code, domain_code = _validate_manifest(manifest, template)
+    incremental = collect_incremental_rows(
+        snapshot_rows,
+        delta_rows,
+        _mapping(manifest.get("source"), "source"),
+    )
+    evidence = _mapping(evidence, "evidence")
+    _reject_secrets(evidence)
+    if evidence.get("package_code") != package_code:
+        raise DomainReplicationError("evidence package code does not match manifest")
+    receipts = evidence.get("receipts")
+    if not isinstance(receipts, list):
+        raise DomainReplicationError("evidence.receipts must be a list")
+    by_stage: dict[str, dict[str, Any]] = {}
+    for receipt in receipts:
+        receipt = _mapping(receipt, "receipt")
+        stage = _bounded_text(receipt.get("stage"), "receipt.stage", 80)
+        if stage in by_stage:
+            raise DomainReplicationError(f"duplicate evidence stage: {stage}")
+        by_stage[stage] = receipt
+    missing = sorted(set(REQUIRED_STAGES) - set(by_stage))
+    extra = sorted(set(by_stage) - set(REQUIRED_STAGES))
+    if missing:
+        raise DomainReplicationError(f"missing stages: {','.join(missing)}")
+    if extra:
+        raise DomainReplicationError(f"unsupported stages: {','.join(extra)}")
+
+    stage_reports: dict[str, dict[str, Any]] = {}
+    for stage in REQUIRED_STAGES:
+        receipt = by_stage[stage]
+        if receipt.get("domain_code") != domain_code:
+            raise DomainReplicationError(f"{stage} evidence has the wrong domain")
+        if receipt.get("status") != "passed":
+            raise DomainReplicationError(f"{stage} evidence status must be passed")
+        if receipt.get("subsystem") != STAGE_SUBSYSTEMS[stage]:
+            raise DomainReplicationError(f"{stage} evidence subsystem is invalid")
+        api_refs = receipt.get("api_refs")
+        if not isinstance(api_refs, list) or not api_refs:
+            raise DomainReplicationError(f"{stage} api_refs are required")
+        if any("/device-" in str(ref) for ref in api_refs):
+            raise DomainReplicationError("device-specific API evidence is not allowed")
+        evidence_refs = receipt.get("evidence_refs")
+        if not isinstance(evidence_refs, list) or not evidence_refs:
+            raise DomainReplicationError(f"{stage} evidence_refs are required")
+        metrics = _mapping(receipt.get("metrics"), f"{stage}.metrics")
+        _validate_stage_metrics(
+            stage,
+            metrics,
+            template=template,
+            incremental=incremental,
+        )
+        stage_reports[stage] = {
+            "status": "passed",
+            "subsystem": STAGE_SUBSYSTEMS[stage],
+            "api_refs": sorted(str(ref) for ref in api_refs),
+            "evidence_refs": sorted(str(ref) for ref in evidence_refs),
+            "metrics": deepcopy(metrics),
+        }
+
+    bindings = manifest.get("enterprise_bindings")
+    if not isinstance(bindings, list) or not bindings:
+        raise DomainReplicationError("enterprise binding checklist is required")
+    binding_statuses: dict[str, str] = {}
+    for item in bindings:
+        item = _mapping(item, "enterprise_bindings.item")
+        kind = _bounded_text(item.get("kind"), "enterprise_bindings.kind")
+        status = _bounded_text(item.get("status"), "enterprise_bindings.status")
+        if status not in {"bound", "unbound"}:
+            raise DomainReplicationError("enterprise binding status must be bound or unbound")
+        if kind in binding_statuses:
+            raise DomainReplicationError(f"duplicate enterprise binding: {kind}")
+        binding_statuses[kind] = status
+    missing_bindings = sorted(_REQUIRED_ENTERPRISE_BINDINGS - set(binding_statuses))
+    if missing_bindings:
+        raise DomainReplicationError(
+            f"missing enterprise bindings: {','.join(missing_bindings)}"
+        )
+    unbound = sorted(
+        kind for kind, status in binding_statuses.items() if status == "unbound"
+    )
+    assessment = manifest["core_change_assessment"]
+    report: dict[str, Any] = {
+        "schema_version": 1,
+        "package_code": package_code,
+        "status": "passed",
+        "domain": {
+            "code": domain_code,
+            "name": _bounded_text(manifest["domain"].get("name"), "domain.name"),
+            "template_version": int(
+                stage_reports["template_initialization"]["metrics"]["template_version"]
+            ),
+        },
+        "incremental_collection": incremental,
+        "stages": stage_reports,
+        "device_specific_core_changes": 0,
+        "generic_extension_count": len(assessment["generic_extensions"]),
+        "third_domain_reusable": True,
+        "enterprise_uat": {
+            "status": "blocked_external" if unbound else "ready",
+            "unbound_requirements": unbound,
+        },
+    }
+    report["report_sha256"] = _digest(report)
+    return report

+ 528 - 0
deployment/app/core/governance/domain_replication.py

@@ -0,0 +1,528 @@
+"""Generic, evidence-bound acceptance for copying a governance domain.
+
+The evaluator consumes a self-contained implementation package.  It validates
+controlled snapshot/delta ingestion and canonical receipts from existing
+platform modules; it does not execute domain-specific business logic or store
+source rows in the report.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from copy import deepcopy
+from typing import Any
+
+REQUIRED_STAGES = (
+    "template_initialization",
+    "incremental_ingestion",
+    "catalog",
+    "semantics",
+    "responsibility",
+    "quality",
+    "remediation",
+    "observability",
+    "data_product",
+    "agent",
+)
+
+STAGE_SUBSYSTEMS = {
+    "template_initialization": "domain_templates",
+    "incremental_ingestion": "active_metadata",
+    "catalog": "active_metadata",
+    "semantics": "semantic_governance",
+    "responsibility": "unified_responsibilities",
+    "quality": "quality_operations",
+    "remediation": "unified_work_center",
+    "observability": "data_observability",
+    "data_product": "product_governance",
+    "agent": "agent_governance",
+}
+
+_CODE = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
+_SECRET = re.compile(
+    r"(?:password|passwd|secret|token|credential|authorization|api[_-]?key)",
+    re.IGNORECASE,
+)
+_DEVICE_PATH_MARKERS = (
+    "app/core/data_research/device_",
+    "app/api/data_development/device_",
+    "frontend/src/views/dataResearch/device",
+)
+_REQUIRED_ENTERPRISE_BINDINGS = {
+    "data_steward",
+    "domain_owner",
+    "readonly_source",
+    "uat_users",
+}
+
+
+class DomainReplicationError(ValueError):
+    """Raised when an implementation package cannot prove a replication gate."""
+
+
+def _canonical(value: Any) -> bytes:
+    return json.dumps(
+        value,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    ).encode("utf-8")
+
+
+def _digest(value: Any) -> str:
+    return hashlib.sha256(_canonical(value)).hexdigest()
+
+
+def _mapping(value: Any, field: str) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise DomainReplicationError(f"{field} must be an object")
+    return value
+
+
+def _bounded_text(value: Any, field: str, limit: int = 256) -> str:
+    rendered = str(value or "").strip()
+    if not rendered or len(rendered) > limit:
+        raise DomainReplicationError(f"{field} must be non-empty and <= {limit}")
+    return rendered
+
+
+def _reject_secrets(value: Any, path: str = "$") -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if _SECRET.search(str(key)):
+                raise DomainReplicationError(
+                    f"secret material is not allowed at {path}.{key}"
+                )
+            _reject_secrets(item, f"{path}.{key}")
+    elif isinstance(value, list):
+        for index, item in enumerate(value):
+            _reject_secrets(item, f"{path}[{index}]")
+
+
+def _row_key(row: dict[str, str], fields: list[str]) -> tuple[str, ...]:
+    values = tuple(str(row.get(field, "")).strip() for field in fields)
+    if any(not value for value in values):
+        raise DomainReplicationError("source primary key values must be non-empty")
+    return values
+
+
+def _normalized_row(
+    row: dict[str, str], *, operation_field: str, cursor_field: str
+) -> dict[str, str]:
+    return {
+        str(key): str(value or "").strip()
+        for key, value in sorted(row.items())
+        if key not in {operation_field, cursor_field}
+    }
+
+
+def _validate_source_fields(rows: list[dict[str, str]]) -> None:
+    for row in rows:
+        if not isinstance(row, dict):
+            raise DomainReplicationError("source rows must be objects")
+        for field in row:
+            if _SECRET.search(str(field)):
+                raise DomainReplicationError(
+                    f"secret-like source field is not allowed: {field}"
+                )
+
+
+def collect_incremental_rows(
+    snapshot_rows: list[dict[str, str]],
+    delta_rows: list[dict[str, str]],
+    source: dict[str, Any],
+) -> dict[str, Any]:
+    """Apply a controlled snapshot and monotonic delta without exposing rows."""
+
+    source = _mapping(source, "source")
+    if source.get("kind") != "controlled_csv":
+        raise DomainReplicationError("source.kind must be controlled_csv")
+    if source.get("classification") != "desensitized":
+        raise DomainReplicationError("source.classification must be desensitized")
+    primary_key = source.get("primary_key")
+    if not isinstance(primary_key, list) or not primary_key:
+        raise DomainReplicationError("source.primary_key must be a non-empty list")
+    primary_key = [_bounded_text(item, "source.primary_key") for item in primary_key]
+    operation_field = _bounded_text(
+        source.get("operation_field", "_operation"), "source.operation_field"
+    )
+    cursor_field = _bounded_text(
+        source.get("cursor_field", "_cursor"), "source.cursor_field"
+    )
+    required_columns = source.get("required_columns")
+    if not isinstance(required_columns, list) or not required_columns:
+        raise DomainReplicationError("source.required_columns must be a non-empty list")
+    required_columns = {
+        _bounded_text(item, "source.required_columns") for item in required_columns
+    }
+    cursor_before = int(source.get("snapshot_cursor", -1))
+    if cursor_before < 0:
+        raise DomainReplicationError("source.snapshot_cursor must be non-negative")
+
+    if not snapshot_rows:
+        raise DomainReplicationError("controlled snapshot must contain rows")
+    if not delta_rows:
+        raise DomainReplicationError("controlled delta must contain rows")
+    _validate_source_fields(snapshot_rows)
+    _validate_source_fields(delta_rows)
+
+    state: dict[tuple[str, ...], dict[str, str]] = {}
+    for row in snapshot_rows:
+        if not required_columns.issubset(row):
+            missing = sorted(required_columns - set(row))
+            raise DomainReplicationError(f"snapshot is missing required columns: {missing}")
+        key = _row_key(row, primary_key)
+        if key in state:
+            raise DomainReplicationError("snapshot primary keys must be unique")
+        state[key] = _normalized_row(
+            row, operation_field=operation_field, cursor_field=cursor_field
+        )
+
+    cursors: list[int] = []
+    for row in delta_rows:
+        if cursor_field not in row or operation_field not in row:
+            raise DomainReplicationError("delta cursor and operation fields are required")
+        try:
+            cursor = int(row[cursor_field])
+        except (TypeError, ValueError) as exc:
+            raise DomainReplicationError("delta cursor must be an integer") from exc
+        if cursor <= cursor_before or (cursors and cursor <= cursors[-1]):
+            raise DomainReplicationError("delta cursors must strictly increase")
+        cursors.append(cursor)
+
+    def apply_delta() -> dict[str, int]:
+        result = {"inserted": 0, "updated": 0, "deleted": 0}
+        for row in delta_rows:
+            operation = str(row[operation_field]).strip().lower()
+            if operation not in {"upsert", "delete"}:
+                raise DomainReplicationError(f"unsupported delta operation: {operation}")
+            key = _row_key(row, primary_key)
+            if operation == "delete":
+                if key in state:
+                    del state[key]
+                    result["deleted"] += 1
+                continue
+            if not required_columns.issubset(row):
+                missing = sorted(required_columns - set(row))
+                raise DomainReplicationError(f"delta is missing required columns: {missing}")
+            normalized = _normalized_row(
+                row, operation_field=operation_field, cursor_field=cursor_field
+            )
+            if key not in state:
+                state[key] = normalized
+                result["inserted"] += 1
+            elif state[key] != normalized:
+                state[key] = normalized
+                result["updated"] += 1
+        return result
+
+    first = apply_delta()
+    first_state = deepcopy(state)
+    replay = apply_delta()
+    if state != first_state:
+        raise DomainReplicationError("delta replay changed final source state")
+    replay_changes = sum(replay.values())
+    final_rows = [state[key] for key in sorted(state)]
+    return {
+        "source_kind": "controlled_csv",
+        "classification": "desensitized",
+        "snapshot_rows": len(snapshot_rows),
+        "delta_rows": len(delta_rows),
+        **first,
+        "final_rows": len(state),
+        "replay_changes": replay_changes,
+        "cursor_before": cursor_before,
+        "cursor_after": cursors[-1],
+        "snapshot_digest": _digest(
+            sorted(
+                (
+                    _normalized_row(
+                        row,
+                        operation_field=operation_field,
+                        cursor_field=cursor_field,
+                    )
+                    for row in snapshot_rows
+                ),
+                key=_canonical,
+            )
+        ),
+        "delta_digest": _digest(
+            [
+                {
+                    **_normalized_row(
+                        row,
+                        operation_field=operation_field,
+                        cursor_field=cursor_field,
+                    ),
+                    operation_field: row[operation_field],
+                    cursor_field: int(row[cursor_field]),
+                }
+                for row in delta_rows
+            ]
+        ),
+        "final_digest": _digest(final_rows),
+    }
+
+
+def _positive(metrics: dict[str, Any], field: str, minimum: float = 1) -> float:
+    try:
+        value = float(metrics.get(field, 0))
+    except (TypeError, ValueError) as exc:
+        raise DomainReplicationError(f"{field} must be numeric") from exc
+    if value < minimum:
+        raise DomainReplicationError(f"{field} must be >= {minimum:g}")
+    return value
+
+
+def _validate_stage_metrics(
+    stage: str,
+    metrics: dict[str, Any],
+    *,
+    template: dict[str, Any],
+    incremental: dict[str, Any],
+) -> None:
+    if stage == "template_initialization":
+        expected = {
+            "object_type_count": len(template.get("object_types", [])),
+            "rule_count": len(template.get("rules", [])),
+            "role_count": len(template.get("responsibility_roles", [])),
+            "metric_count": len(template.get("metrics", [])),
+        }
+        if any(int(metrics.get(key, -1)) != value for key, value in expected.items()):
+            raise DomainReplicationError("template initialization counts do not match template")
+        _positive(metrics, "template_version")
+    elif stage == "incremental_ingestion":
+        for key in (
+            "snapshot_rows",
+            "delta_rows",
+            "inserted",
+            "updated",
+            "deleted",
+            "final_rows",
+            "replay_changes",
+            "cursor_before",
+            "cursor_after",
+        ):
+            if int(metrics.get(key, -1)) != int(incremental[key]):
+                raise DomainReplicationError(
+                    f"incremental ingestion evidence does not match collected {key}"
+                )
+    elif stage == "catalog":
+        _positive(metrics, "asset_count", 3)
+        _positive(metrics, "incremental_change_count")
+        if int(metrics.get("cursor_after", -1)) != incremental["cursor_after"]:
+            raise DomainReplicationError("catalog cursor does not match ingestion cursor")
+    elif stage == "semantics":
+        for field in (
+            "published_term_count",
+            "published_code_set_count",
+            "published_metric_count",
+            "mapped_field_count",
+        ):
+            _positive(metrics, field)
+    elif stage == "responsibility":
+        if float(metrics.get("coverage_percent", 0)) != 100:
+            raise DomainReplicationError("responsibility coverage must be 100 percent")
+        _positive(metrics, "bound_role_count", 3)
+    elif stage == "quality":
+        _positive(metrics, "published_rule_count", 5)
+        _positive(metrics, "initial_finding_count")
+        target = float(metrics.get("target_score", 0))
+        final = float(metrics.get("final_score", 0))
+        if target <= 0 or final < target:
+            raise DomainReplicationError("quality final score must meet target score")
+    elif stage == "remediation":
+        expected = {
+            "issue_status": "closed",
+            "task_status": "completed",
+            "independent_closer": True,
+        }
+        if metrics != expected:
+            raise DomainReplicationError("remediation issue and task must be independently closed")
+    elif stage == "observability":
+        _positive(metrics, "slo_count")
+        if metrics.get("incident_status") != "closed" or metrics.get("recovered") is not True:
+            raise DomainReplicationError("observability incident must recover and close")
+    elif stage == "data_product":
+        if metrics.get("contract_status") != "active":
+            raise DomainReplicationError("data product contract must be active")
+        if metrics.get("certificate_status") != "issued":
+            raise DomainReplicationError("data product certificate must be issued")
+        refs = metrics.get("certificate_evidence_refs")
+        if not isinstance(refs, list) or len(refs) < 4:
+            raise DomainReplicationError("certificate evidence must bind quality, lineage, rule and workflow")
+        required = ("quality://", "lineage://", "rule://", "workflow://")
+        if not all(any(str(ref).startswith(prefix) for ref in refs) for prefix in required):
+            raise DomainReplicationError("certificate evidence is missing canonical references")
+    elif stage == "agent":
+        if metrics.get("autonomy_level") not in {"read_only", "suggestion"}:
+            raise DomainReplicationError("agent autonomy must be read_only or suggestion")
+        if metrics.get("decision") != "authorized":
+            raise DomainReplicationError("agent action must be authorized")
+        if metrics.get("automatic_execution_allowed") is not False:
+            raise DomainReplicationError("agent automatic execution must remain disabled")
+        _positive(metrics, "citation_count")
+        if metrics.get("cross_domain_denied") is not True:
+            raise DomainReplicationError("agent cross-domain request must be denied")
+
+
+def _validate_manifest(manifest: dict[str, Any], template: dict[str, Any]) -> tuple[str, str]:
+    manifest = _mapping(manifest, "manifest")
+    _reject_secrets(manifest)
+    if int(manifest.get("schema_version", 0)) != 1:
+        raise DomainReplicationError("manifest.schema_version must be 1")
+    package_code = _bounded_text(manifest.get("package_code"), "package_code", 80)
+    domain = _mapping(manifest.get("domain"), "domain")
+    domain_code = _bounded_text(domain.get("code"), "domain.code", 64)
+    if not _CODE.fullmatch(domain_code):
+        raise DomainReplicationError("domain.code must be a stable lowercase code")
+    if template.get("template_code") != domain_code:
+        raise DomainReplicationError("domain template code does not match package domain")
+    if len(template.get("object_types", [])) < 3:
+        raise DomainReplicationError("domain template must contain at least three object types")
+    if len(template.get("rules", [])) < 5:
+        raise DomainReplicationError("domain template must contain at least five rules")
+
+    assessment = _mapping(
+        manifest.get("core_change_assessment"), "core_change_assessment"
+    )
+    device_changes = assessment.get("device_specific_changes")
+    if not isinstance(device_changes, list):
+        raise DomainReplicationError("device_specific_changes must be a list")
+    if device_changes:
+        raise DomainReplicationError("device-specific core changes must remain zero")
+    generic_extensions = assessment.get("generic_extensions")
+    if not isinstance(generic_extensions, list) or not generic_extensions:
+        raise DomainReplicationError("generic extension points must be documented")
+    if any(
+        str(path).startswith(marker)
+        for path in generic_extensions
+        for marker in _DEVICE_PATH_MARKERS
+    ):
+        raise DomainReplicationError("generic extensions cannot target device-specific paths")
+
+    reuse = _mapping(manifest.get("third_domain_reuse"), "third_domain_reuse")
+    if reuse.get("reusable") is not True:
+        raise DomainReplicationError("third-domain reuse must be explicitly enabled")
+    placeholders = reuse.get("required_replacements")
+    if not isinstance(placeholders, list) or len(placeholders) < 4:
+        raise DomainReplicationError("third-domain replacement checklist is incomplete")
+    return package_code, domain_code
+
+
+def evaluate_replication_package(
+    manifest: dict[str, Any],
+    evidence: dict[str, Any],
+    *,
+    template: dict[str, Any],
+    snapshot_rows: list[dict[str, str]],
+    delta_rows: list[dict[str, str]],
+) -> dict[str, Any]:
+    """Evaluate one domain package and return a deterministic, row-free report."""
+
+    package_code, domain_code = _validate_manifest(manifest, template)
+    incremental = collect_incremental_rows(
+        snapshot_rows,
+        delta_rows,
+        _mapping(manifest.get("source"), "source"),
+    )
+    evidence = _mapping(evidence, "evidence")
+    _reject_secrets(evidence)
+    if evidence.get("package_code") != package_code:
+        raise DomainReplicationError("evidence package code does not match manifest")
+    receipts = evidence.get("receipts")
+    if not isinstance(receipts, list):
+        raise DomainReplicationError("evidence.receipts must be a list")
+    by_stage: dict[str, dict[str, Any]] = {}
+    for receipt in receipts:
+        receipt = _mapping(receipt, "receipt")
+        stage = _bounded_text(receipt.get("stage"), "receipt.stage", 80)
+        if stage in by_stage:
+            raise DomainReplicationError(f"duplicate evidence stage: {stage}")
+        by_stage[stage] = receipt
+    missing = sorted(set(REQUIRED_STAGES) - set(by_stage))
+    extra = sorted(set(by_stage) - set(REQUIRED_STAGES))
+    if missing:
+        raise DomainReplicationError(f"missing stages: {','.join(missing)}")
+    if extra:
+        raise DomainReplicationError(f"unsupported stages: {','.join(extra)}")
+
+    stage_reports: dict[str, dict[str, Any]] = {}
+    for stage in REQUIRED_STAGES:
+        receipt = by_stage[stage]
+        if receipt.get("domain_code") != domain_code:
+            raise DomainReplicationError(f"{stage} evidence has the wrong domain")
+        if receipt.get("status") != "passed":
+            raise DomainReplicationError(f"{stage} evidence status must be passed")
+        if receipt.get("subsystem") != STAGE_SUBSYSTEMS[stage]:
+            raise DomainReplicationError(f"{stage} evidence subsystem is invalid")
+        api_refs = receipt.get("api_refs")
+        if not isinstance(api_refs, list) or not api_refs:
+            raise DomainReplicationError(f"{stage} api_refs are required")
+        if any("/device-" in str(ref) for ref in api_refs):
+            raise DomainReplicationError("device-specific API evidence is not allowed")
+        evidence_refs = receipt.get("evidence_refs")
+        if not isinstance(evidence_refs, list) or not evidence_refs:
+            raise DomainReplicationError(f"{stage} evidence_refs are required")
+        metrics = _mapping(receipt.get("metrics"), f"{stage}.metrics")
+        _validate_stage_metrics(
+            stage,
+            metrics,
+            template=template,
+            incremental=incremental,
+        )
+        stage_reports[stage] = {
+            "status": "passed",
+            "subsystem": STAGE_SUBSYSTEMS[stage],
+            "api_refs": sorted(str(ref) for ref in api_refs),
+            "evidence_refs": sorted(str(ref) for ref in evidence_refs),
+            "metrics": deepcopy(metrics),
+        }
+
+    bindings = manifest.get("enterprise_bindings")
+    if not isinstance(bindings, list) or not bindings:
+        raise DomainReplicationError("enterprise binding checklist is required")
+    binding_statuses: dict[str, str] = {}
+    for item in bindings:
+        item = _mapping(item, "enterprise_bindings.item")
+        kind = _bounded_text(item.get("kind"), "enterprise_bindings.kind")
+        status = _bounded_text(item.get("status"), "enterprise_bindings.status")
+        if status not in {"bound", "unbound"}:
+            raise DomainReplicationError("enterprise binding status must be bound or unbound")
+        if kind in binding_statuses:
+            raise DomainReplicationError(f"duplicate enterprise binding: {kind}")
+        binding_statuses[kind] = status
+    missing_bindings = sorted(_REQUIRED_ENTERPRISE_BINDINGS - set(binding_statuses))
+    if missing_bindings:
+        raise DomainReplicationError(
+            f"missing enterprise bindings: {','.join(missing_bindings)}"
+        )
+    unbound = sorted(
+        kind for kind, status in binding_statuses.items() if status == "unbound"
+    )
+    assessment = manifest["core_change_assessment"]
+    report: dict[str, Any] = {
+        "schema_version": 1,
+        "package_code": package_code,
+        "status": "passed",
+        "domain": {
+            "code": domain_code,
+            "name": _bounded_text(manifest["domain"].get("name"), "domain.name"),
+            "template_version": int(
+                stage_reports["template_initialization"]["metrics"]["template_version"]
+            ),
+        },
+        "incremental_collection": incremental,
+        "stages": stage_reports,
+        "device_specific_core_changes": 0,
+        "generic_extension_count": len(assessment["generic_extensions"]),
+        "third_domain_reusable": True,
+        "enterprise_uat": {
+            "status": "blocked_external" if unbound else "ready",
+            "unbound_requirements": unbound,
+        },
+    }
+    report["report_sha256"] = _digest(report)
+    return report

+ 14 - 6
docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md

@@ -519,16 +519,24 @@ Alembic head、环境配置和锁定依赖,包内生成 CycloneDX 1.5 SBOM 与
 
 **主要工作:**
 
-- [ ] 使用领域模板初始化对象、术语、责任、规则、指标和任务。
-- [ ] 接入至少一个真实或脱敏数据源并完成增量采集。
-- [ ] 完成资产目录、术语标准、质量、问题整改和可观测闭环。
-- [ ] 发布至少一个带数据合同和合格证的数据产品。
-- [ ] 使用受治理 Agent 完成只读检索或建议场景。
-- [ ] 记录所有需要修改核心代码的事项并评估平台化缺口。
+- [x] 使用领域模板初始化对象、术语、责任、规则、指标和任务。
+- [x] 接入至少一个真实或脱敏数据源并完成增量采集。
+- [x] 完成资产目录、术语标准、质量、问题整改和可观测闭环。
+- [x] 发布至少一个带数据合同和合格证的数据产品。
+- [x] 使用受治理 Agent 完成只读检索或建议场景。
+- [x] 记录所有需要修改核心代码的事项并评估平台化缺口。
 
 **完成门禁:** 第二业务域不复制设备域服务即可完成端到端治理;领域差异通过
 模板、配置或明确扩展接口解决;形成可供第三个领域复用的实施包。
 
+**工程状态:** 已完成本地工程门禁。默认“备品备件/物料主数据”通过领域模板和
+受控脱敏 CSV 完成 3 行快照、2 行单调增量及幂等重放,形成目录、语义、责任、质量、
+整改、可观测、数据产品合格证和只读 Agent 的十阶段证据链。相对 P2-WP11 基线未修改
+设备域专用服务,只新增通用实施包评估器和验证器;实施包可供第三业务域替换配置复用。
+企业真实负责人、只读来源、正式业务配置和真实用户 UAT 尚未绑定,因此当前状态为
+“本地工程完成,可进入企业 UAT”,不等同于业务验收或生产就绪。详见
+`docs/phase2/P2_WP12_SECOND_DOMAIN_REPLICATION.md`。
+
 ### P2-WP13 第二阶段验收与移交
 
 **目标:** 形成可复核的第二阶段完成证据。

+ 14 - 9
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -470,7 +470,7 @@ DataOps Platform 当前已经具备较完整的“治理对象 → 知识服务
 | 模块编号 | 模块分级 | 功能项 | 成熟度 |
 |---|---|---|---|
 | GOV-01 | 治理组织 / 业务域 | 业务域列表、详情、创建、更新、删除和图谱 | 已建设 |
-| GOV-02 | 治理组织 / 联邦治理 | 中央团队制定政策、业务域自治运营的联邦治理模型 | 部分建设 |
+| GOV-02 | 治理组织 / 联邦治理 | 中央团队制定政策、业务域自治运营的联邦治理模型 | 工程完成,待企业组织与策略验收;中央策略、继承覆盖及第二业务域责任证据已形成 |
 | GOV-03 | 治理组织 / 责任角色 | 业务域 Owner、Data Steward、数据架构师和资产管理员 | 后端与管理界面完成,待企业配置 |
 | GOV-04 | 治理组织 / 责任矩阵 | 资产、标准、本体、质量和数据产品的 RACI | 设备域完成,其他对象待扩展 |
 | GOV-05 | 治理组织 / 责任继承 | 组织、业务域、资产层级的责任人继承与覆盖 | 规划中 |
@@ -517,7 +517,7 @@ DataOps Platform 当前已经具备较完整的“治理对象 → 知识服务
 |---|---|---|---|
 | CAT-01 | 资产目录 / 元数据 | 元数据列表、搜索、详情、创建、编辑和删除 | 已建设 |
 | CAT-02 | 资产目录 / 数据地图 | 元数据、业务域、标准、流程和标签的目录浏览 | 已建设 |
-| CAT-03 | 资产目录 / 全类型资产 | 统一管理数据、语义、流程、产品、BI、AI 和治理制度资产 | 部分建设 |
+| CAT-03 | 资产目录 / 全类型资产 | 统一管理数据、语义、流程、产品、BI、AI 和治理制度资产 | 部分建设;第二业务域已验证数据、语义、流程、产品、Agent 和治理证据统一归档,BI 资产待建设 |
 | CAT-04 | 资产目录 / 物理资产 | 数据库、Schema、表、字段、视图、文件和对象 | 部分建设 |
 | CAT-05 | 资产目录 / 执行资产 | SQL、ETL 作业、DataFlow、调度任务和运行实例 | 部分建设 |
 | CAT-06 | 资产目录 / 服务资产 | 数据产品、API、订阅和数据合同 | 部分建设 |
@@ -527,15 +527,15 @@ DataOps Platform 当前已经具备较完整的“治理对象 → 知识服务
 | CAT-10 | 资产发现 / 增量采集 | 基于游标、事件或快照差异的增量更新 | 部分建设 |
 | CAT-11 | 资产搜索 / 关键词搜索 | 名称、描述、标签和全文检索 | 工程完成;设备名称、平台 UID、授权源 ID、位置、组织、责任人和运行事件已接入,待企业检索集验收 |
 | CAT-12 | 资产搜索 / 自然语言找数 | 自然语言定位资产、解释指标和推荐数据产品 | 部分建设;设备域授权检索和证据问答已形成,不扩展 NL2SQL、在线分析或通用数据产品推荐 |
-| CAT-13 | 资产详情 / 统一档案 | 基本信息、来源、责任人、质量、权限、血缘和版本 | 部分建设 |
+| CAT-13 | 资产详情 / 统一档案 | 基本信息、来源、责任人、质量、权限、血缘和版本 | 部分建设;第二业务域已验证来源、责任、质量、权限与版本证据组合,跨来源统一详情及企业数据待验收 |
 | CAT-14 | 资产标识 / 稳定 UID | 跨系统稳定业务标识和源系统 ID 映射 | 部分建设 |
 | CAT-15 | 资产标签 / 标签体系 | 标签新增、详情、列表、识别、图谱和删除 | 已建设 |
 | CAT-16 | 资产血缘 / 图谱血缘 | 业务域、DataFlow、产品、元数据和数据源血缘 | 已建设 |
 | CAT-17 | 资产血缘 / 字段血缘 | SQL 解析和作业采集形成字段级血缘 | 规划中 |
 | CAT-18 | 资产血缘 / 运行血缘 | 将实际运行批次、输入、输出和制品关联到设计血缘 | 部分建设 |
 | CAT-19 | 资产影响 / 变更影响 | Schema、标准、本体和规则变更的下游影响分析 | 部分建设 |
-| CAT-20 | 资产生命周期 / 状态管理 | 草稿、审核、发布、停用、废弃和删除 | 部分建设 |
-| CAT-21 | 资产版本 / 历史差异 | 版本快照、差异、回滚和来源追溯 | 部分建设 |
+| CAT-20 | 资产生命周期 / 状态管理 | 草稿、审核、发布、停用、废弃和删除 | 部分建设;领域模板、语义、规则、流程、产品和 Agent 已有受控状态,跨类型统一生命周期仍待建设 |
+| CAT-21 | 资产版本 / 历史差异 | 版本快照、差异、回滚和来源追溯 | 部分建设;第二业务域包已绑定模板、来源、证据与报告摘要,跨类型统一差异/回滚视图待建设 |
 | CAT-22 | 主动元数据 / 使用热度 | 浏览、查询、订阅、运行和复用热度 | 部分建设 |
 | CAT-23 | 主动元数据 / 健康信号 | 质量、SLA、故障和漂移信号回写目录 | 规划中 |
 | CAT-24 | 主动元数据 / 推荐 | 基于角色、业务域、血缘和使用行为推荐资产 | 规划中 |
@@ -552,6 +552,11 @@ WP-08 已补齐设备质量问题的工程闭环:可从 WP-07 违规样本批
 
 WP-09 已形成设备关系与根因的最小工程链:告警、故障、维修和停机事件按来源身份不可变、幂等接入,设备资产、运行事件和质量问题通过带证据的有向关系连接;工作台最多展示三跳、100 个节点和 200 条关系。根因分析只沿已保存的上游因果证据关系返回候选及路径,证据不足时明确返回“无法确认根因”,不会生成确定性结论、自动修复或维修计划。企业真实运行事件、关系口径和设备专家结论仍待现场验收;跨数据产品、报表、Agent 和业务域的影响分析及 AI 修复建议不在 WP-09。
 
+P2-WP12 已使用“备品备件/物料主数据”脱敏样本完成第二业务域工程复制:六类对象模板、
+单调增量采集及幂等重放,目录、语义、责任、质量整改、可观测、数据产品合格证和只读
+Agent 共十阶段证据由同一实施包绑定,未修改设备域专用服务。当前结论是可进入企业 UAT;
+真实负责人、只读来源、正式业务配置和用户验收仍为外部门禁,不能据此标记生产就绪。
+
 ### 12.5 数据标准、语义与本体
 
 | 模块编号 | 模块分级 | 功能项 | 成熟度 |
@@ -586,7 +591,7 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | DQA-01 | 数据质量 / 规则定义 | 自然语言规则、封闭 RuleSpec 和版本 | 工程完成,受门禁 |
 | DQA-02 | 数据质量 / 数据画像 | 完整率、唯一性、分布、空值、模式和样例画像 | 工程完成,待企业数据验收;通用画像覆盖完整率、唯一性、分布、空值、模式及不可逆样例摘要 |
 | DQA-03 | 数据质量 / 规则执行 | SQL 下推、Polars 批处理和 `quality.check` | 工程完成,受门禁 |
-| DQA-04 | 数据质量 / 质量评分 | 资产、数据产品和业务域的质量评分 | 部分建设;通用资产执行评分及业务域归属已形成,数据产品评分和业务域聚合评分待 P2-WP08、P2-WP12 验收 |
+| DQA-04 | 数据质量 / 质量评分 | 资产、数据产品和业务域的质量评分 | 部分建设;第二业务域已绑定资产执行、整改后评分及产品合格证证据,业务域聚合评分待建设 |
 | DQA-05 | 数据质量 / 质量趋势 | 质量指标时间序列、同比、环比和退化趋势 | 工程完成,待企业统计窗口验收;已形成批次时间序列、上批退化、同比和环比比较 |
 | DQA-06 | 数据质量 / 异常检测 | 数据量、分布、模式、重复和异常值检测 | 工程完成,待企业阈值验收;所有发现均由确定性统计生成 |
 | DQA-07 | 数据质量 / Schema 漂移 | 字段、类型、约束和枚举变化检测 | 部分建设;主动元数据字段变化和质量画像 Schema 退化已形成,通用约束及枚举漂移待扩展 |
@@ -594,14 +599,14 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | DQA-09 | 数据质量 / 质量 SLA | 质量目标、阈值、窗口、违约和升级 | 工程完成,待 P2-WP05/P2-WP07 事故与通知联调;已形成新鲜度、质量得分违约、恢复和升级级别事件 |
 | DQA-10 | 质量运营 / 质量问题 | 问题创建、分类、影响、责任人和优先级 | 工程完成,待企业质量问题验收 |
 | DQA-11 | 质量运营 / 整改工单 | 分派、整改、复核、关闭、重开和逾期 | 工程完成,待企业整改流程验收 |
-| DQA-12 | 质量运营 / 复发分析 | 重复问题识别、复发率和治理效果 | 部分建设;设备问题组及通用质量发现的确定性复发次数、质量趋势已形成,跨资产治理效果聚合待 P2-WP12 验收 |
+| DQA-12 | 质量运营 / 复发分析 | 重复问题识别、复发率和治理效果 | 部分建设;设备与第二业务域均已验证确定性质量发现和整改证据,跨资产治理效果聚合待建设 |
 | DQA-13 | 质量智能 / 根因分析 | 结合血缘、变更和运行证据分析根因 | 部分建设;设备关系路径及通用血缘、变更、运行和责任证据已融合,不做无证据因果推断,专家验收待完成 |
 | DQA-14 | 质量智能 / 影响分析 | 识别受影响资产、产品、报表、Agent 和业务域 | 部分建设;设备资产、运行事件和质量问题的关系影响已形成,产品、报表、Agent 和业务域影响待建设 |
 | DQA-15 | 质量智能 / 修复建议 | AI 生成受证据约束的修复建议 | 规划中;WP-09 只返回根因候选,不生成修复动作 |
 | DQA-16 | 质量智能 / 自动修复 | 低风险受控执行,高风险人工审批和回滚 | 规划中 |
 | OBS-01 | 数据可观测 / 任务运行 | 工作流执行、状态、失败、重试和日志 | 已建设 |
 | OBS-02 | 数据可观测 / 生产线 | DataFlow/Deployment、Runner 和制品运行证据 | 工程完成,受门禁 |
-| OBS-03 | 数据可观测 / 数据 SLA | 端到端新鲜度、完整性、质量和交付 SLA | 规划中 |
+| OBS-03 | 数据可观测 / 数据 SLA | 端到端新鲜度、完整性、质量和交付 SLA | 工程完成,待企业 SLO 验收;四类 SLI/SLO 及第二业务域新鲜度/质量事故证据已形成 |
 | OBS-04 | 数据可观测 / 事故管理 | 告警聚合、事故、责任人、时间线和复盘 | 规划中 |
 | OBS-05 | 数据可观测 / 告警治理 | 告警抑制、去重、升级、值班和送达回执 | 规划中 |
 | OBS-06 | 数据可观测 / 业务影响 | 将技术异常映射到数据产品、业务域和用户影响 | 规划中 |
@@ -699,7 +704,7 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 |---|---|---|---|
 | WFC-01 | 审批中心 / 元数据审核 | 冗余、变更、别名、合并和忽略 | 已建设 |
 | WFC-02 | 审批中心 / 数据订单 | 分析、审批、驳回、交付和完成 | 已建设 |
-| WFC-03 | 审批中心 / 通用流程 | 配置化的申请、治理、发布和高风险动作审批 | 规划中 |
+| WFC-03 | 审批中心 / 通用流程 | 配置化的申请、治理、发布和高风险动作审批 | 工程完成,受源模块状态门禁;第二业务域整改与产品/Agent 证据已复用统一任务契约,待企业流程验收 |
 | WFC-04 | 审批中心 / 条件路由 | 按业务域、敏感级别、风险、金额和环境路由 | 规划中 |
 | WFC-05 | 审批中心 / 多人审批 | 会签、或签、双人复核、加签和转签 | 规划中 |
 | WFC-06 | 审批中心 / 时限管理 | 截止时间、催办、超时、升级和自动关闭 | 规划中 |

+ 62 - 0
docs/phase2/P2_WP12_SECOND_DOMAIN_REPLICATION.md

@@ -0,0 +1,62 @@
+# P2-WP12 第二业务域复制工程说明
+
+## 1. 交付结论
+
+P2-WP12 已完成本地工程复制门禁。平台以“备品备件/物料主数据”为默认第二业务域,
+复用 P2-WP01~P2-WP11 的通用治理能力,形成模板初始化、脱敏增量采集、资产目录、
+语义标准、责任、质量、整改、可观测、数据产品和受治理 Agent 共十个阶段的可复核链。
+
+本次没有复制或修改设备域专用服务。领域差异保存在模板、样本、证据收据、合同和授权
+配置中;新增代码仅为与业务域无关的实施包验证器。因此当前结论是“本地工程完成,可
+进入企业 UAT”,不是企业真实数据接入、业务验收或生产发布完成。
+
+## 2. 第二业务域实施范围
+
+| 阶段 | 工程结果 | 复用的平台能力 |
+|---|---|---|
+| 模板初始化 | 6 类对象、3 类责任、8 条规则、3 项指标 | 领域模板 |
+| 增量采集 | 3 行脱敏快照、2 行增量,新增 1、更新 1,重复执行 0 变更 | 主动元数据 |
+| 资产目录 | 6 类资产及增量游标形成目录证据 | 主动元数据 |
+| 语义标准 | 4 个术语、3 个代码集、3 个指标、6 个字段映射 | 语义治理 |
+| 责任体系 | 3 类责任角色覆盖率 100% | 统一责任体系 |
+| 质量运营 | 8 条规则,发现 1 个问题,整改后样本得分 100 | 通用质量运营 |
+| 整改闭环 | 工单已完成、问题已关闭、关闭人与整改人独立 | 统一工作中心 |
+| 数据可观测 | 2 项 SLO,事故恢复并关闭 | 数据可观测与事故 |
+| 数据产品 | 物料主数据产品合同生效并签发合格证 | 数据产品治理 |
+| 受治理 Agent | 只读检索获授权、2 条引用、跨域访问被拒绝 | Agent 治理 |
+
+上述数字来自受控脱敏工程样本和规范化证据收据,不代表企业生产规模或运营成效。
+
+## 3. 增量与安全约束
+
+- 来源只允许受控 CSV 或已声明的脱敏来源,主键、必填列、操作类型和游标必须显式配置;
+- 增量游标必须严格递增,同一批次重放必须为幂等;
+- 验证报告只保存行数、游标与 SHA-256,不输出物料编码、名称或其他源数据行;
+- Manifest 对模板、证据、快照、增量和验收报告逐文件绑定摘要;
+- 包内出现秘密字段、设备域 API、设备专用代码修改、失败阶段或证据引用缺失时失败关闭;
+- Agent 仅允许只读或建议场景,本包不允许自动执行,并验证跨业务域拒绝。
+
+## 4. 平台化缺口评估
+
+相对基线提交 `8ec8caa`,设备域专用代码改动数为 0。为使复制结果可以独立验证,本次仅
+增加两个通用扩展点:领域复制包评估器和命令行验证器。它们不导入物料域或设备域模块,
+也不写平台业务数据。
+
+当前没有发现需要复制设备域服务才能完成治理链的阻断性平台缺口。仍需在企业环境解决
+的事项属于实施绑定:真实责任人、正式术语与阈值、只读来源及网络、正式告警渠道、数据
+产品条款和 UAT 用户。这些事项不能用本地样本或开发人员身份代替。
+
+## 5. 可复用实施包
+
+实施包位于 `docs/phase2/p2-wp12-spare-parts-package/`,包含领域模板、脱敏快照与增量、
+规范化证据收据、摘要清单和确定性验收报告。复制第三业务域时,应替换领域模板、来源、
+责任、语义/规则/流程、产品合同与 Agent 授权,保留阶段契约、摘要绑定、失败关闭和报告
+结构。具体步骤见 `docs/runbooks/P2_WP12_THIRD_DOMAIN_REPLICATION_GUIDE.md`。
+
+## 6. 企业 UAT 前置条件
+
+1. 企业确认业务域 Owner、Data Steward、验收负责人和真实 UAT 用户;
+2. 批准只读数据源、网络、账号引用、字段字典、脱敏方案、增量游标和回退方式;
+3. 业务人员签认术语、代码集、质量阈值、整改时限、SLO、产品合同和 Agent 范围;
+4. 在隔离企业环境执行实际端到端 UAT,并验证容量、告警、审计、备份和回滚;
+5. 关闭 P0/P1 缺陷后,才可由 P2-WP13 形成第二阶段正式验收结论。

+ 48 - 0
docs/phase2/p2-wp12-spare-parts-package/README.md

@@ -0,0 +1,48 @@
+# 备品备件/物料主数据领域复制实施包
+
+## 1. 用途
+
+本实施包用于 P2-WP12 本地工程复制验收。它把 P2-WP01~P2-WP11 已建设的通用能力
+组合为一条可复核链,不新增物料域专用服务,也不调用设备域专用 API。
+
+包内数据是人工构造的受控脱敏样本,不来自企业生产系统。它可以证明模板、增量采集和
+跨模块证据契约可执行,但不能替代生产验收。
+
+## 2. 包内容
+
+- `domain-template.json`:六类对象、三类责任、八条规则和三项指标的领域模板快照;
+- `data/material_master_snapshot.csv`:3 行脱敏全量快照;
+- `data/material_master_delta.csv`:1 行更新、1 行新增的单调增量;
+- `evidence.json`:领域模板、主动元数据、语义、责任、质量、整改、可观测、数据产品和
+  Agent 的规范化证据收据;
+- `acceptance-report.json`:由验证器确定性生成、不含源数据行的工程验收报告;
+- `manifest.json`:所有文件摘要、源配置、平台扩展点、企业门禁和第三业务域替换清单。
+
+## 3. 验证
+
+```bash
+.venv/bin/python scripts/validate_domain_replication.py \
+  --package-dir docs/phase2/p2-wp12-spare-parts-package \
+  --output /tmp/p2-wp12-replication-report.json
+```
+
+验证器只输出行数、游标和摘要,不输出物料编码、名称、规格等源数据。重复执行相同增量
+必须产生 `replay_changes=0`。
+
+## 4. 企业 UAT 前置条件
+
+当前以下信息保持外部阻塞,不由开发人员虚构:
+
+- 企业真实负责人、数据责任人和验收负责人;
+- 企业只读数据源、网络、账号引用、字段字典和脱敏批准;
+- 企业正式术语、代码集、质量阈值、整改时限和数据产品使用条款;
+- 真实用户 UAT、培训、问题确认和签字安排。
+
+企业真实数据不得直接覆盖包内样本。应复制本包,在批准的安全环境替换来源和业务配置,
+重新生成摘要并完整执行同一门禁。
+
+## 5. 第三业务域复用
+
+复制到第三业务域时,只替换领域模板、来源快照/增量、责任角色、语义与规则、工作流、
+产品合同和 Agent 授权范围;保留验证器、阶段编号、证据字段、失败关闭和回滚原则。
+若必须修改设备域专用服务,视为平台化缺口,不能把修改隐藏在领域实施包中。

+ 207 - 0
docs/phase2/p2-wp12-spare-parts-package/acceptance-report.json

@@ -0,0 +1,207 @@
+{
+  "device_specific_core_changes": 0,
+  "domain": {
+    "code": "spare_parts",
+    "name": "备品备件/物料主数据",
+    "template_version": 1
+  },
+  "enterprise_uat": {
+    "status": "blocked_external",
+    "unbound_requirements": [
+      "data_steward",
+      "domain_owner",
+      "readonly_source",
+      "uat_users"
+    ]
+  },
+  "generic_extension_count": 2,
+  "incremental_collection": {
+    "classification": "desensitized",
+    "cursor_after": 102,
+    "cursor_before": 100,
+    "deleted": 0,
+    "delta_digest": "59b7574b22a57e6318db96191b1bc0ddf9820822d23a1922b2e57081a80c44f0",
+    "delta_rows": 2,
+    "final_digest": "6ed229d7cfe7590031fec218e8fd3d785212535cd2b077a7adf1c0a3277b7770",
+    "final_rows": 4,
+    "inserted": 1,
+    "replay_changes": 0,
+    "snapshot_digest": "4936c1ffd67aa4e4d6269e4ba4ad5d59816f32e9ee6cf30149487faeb7aa8227",
+    "snapshot_rows": 3,
+    "source_kind": "controlled_csv",
+    "updated": 1
+  },
+  "package_code": "p2_wp12_spare_parts_v1",
+  "report_sha256": "fd366d5ceec8142e145c0170dbe22766fd47f403ec3ec871b7027ac942a621df",
+  "schema_version": 1,
+  "stages": {
+    "agent": {
+      "api_refs": [
+        "/api/knowledge/agents"
+      ],
+      "evidence_refs": [
+        "agent-request://spare_parts/read-001"
+      ],
+      "metrics": {
+        "automatic_execution_allowed": false,
+        "autonomy_level": "read_only",
+        "citation_count": 2,
+        "cross_domain_denied": true,
+        "decision": "authorized"
+      },
+      "status": "passed",
+      "subsystem": "agent_governance"
+    },
+    "catalog": {
+      "api_refs": [
+        "/api/meta/active-metadata"
+      ],
+      "evidence_refs": [
+        "catalog-run://spare_parts/batch-002"
+      ],
+      "metrics": {
+        "asset_count": 6,
+        "cursor_after": 102,
+        "incremental_change_count": 2
+      },
+      "status": "passed",
+      "subsystem": "active_metadata"
+    },
+    "data_product": {
+      "api_refs": [
+        "/api/dataservice/governance/products"
+      ],
+      "evidence_refs": [
+        "product://spare_parts/material-master-v1"
+      ],
+      "metrics": {
+        "certificate_evidence_refs": [
+          "quality://spare_parts/run-final",
+          "lineage://spare_parts/material-master",
+          "rule://spare_parts/quality-v1",
+          "workflow://spare_parts/remediation-001"
+        ],
+        "certificate_status": "issued",
+        "contract_status": "active"
+      },
+      "status": "passed",
+      "subsystem": "product_governance"
+    },
+    "incremental_ingestion": {
+      "api_refs": [
+        "/api/meta/active-metadata"
+      ],
+      "evidence_refs": [
+        "ingestion://spare_parts/material_master/cursor-102"
+      ],
+      "metrics": {
+        "cursor_after": 102,
+        "cursor_before": 100,
+        "deleted": 0,
+        "delta_rows": 2,
+        "final_rows": 4,
+        "inserted": 1,
+        "replay_changes": 0,
+        "snapshot_rows": 3,
+        "updated": 1
+      },
+      "status": "passed",
+      "subsystem": "active_metadata"
+    },
+    "observability": {
+      "api_refs": [
+        "/api/datafactory/observability"
+      ],
+      "evidence_refs": [
+        "incident://spare_parts/freshness-001"
+      ],
+      "metrics": {
+        "incident_status": "closed",
+        "recovered": true,
+        "slo_count": 2
+      },
+      "status": "passed",
+      "subsystem": "data_observability"
+    },
+    "quality": {
+      "api_refs": [
+        "/api/rules/quality-operations"
+      ],
+      "evidence_refs": [
+        "quality://spare_parts/run-final"
+      ],
+      "metrics": {
+        "final_score": 100,
+        "initial_finding_count": 1,
+        "published_rule_count": 8,
+        "target_score": 95
+      },
+      "status": "passed",
+      "subsystem": "quality_operations"
+    },
+    "remediation": {
+      "api_refs": [
+        "/api/system/work-center"
+      ],
+      "evidence_refs": [
+        "workflow://spare_parts/remediation-001"
+      ],
+      "metrics": {
+        "independent_closer": true,
+        "issue_status": "closed",
+        "task_status": "completed"
+      },
+      "status": "passed",
+      "subsystem": "unified_work_center"
+    },
+    "responsibility": {
+      "api_refs": [
+        "/api/system/responsibilities"
+      ],
+      "evidence_refs": [
+        "responsibility://spare_parts/policy-v1"
+      ],
+      "metrics": {
+        "bound_role_count": 3,
+        "coverage_percent": 100
+      },
+      "status": "passed",
+      "subsystem": "unified_responsibilities"
+    },
+    "semantics": {
+      "api_refs": [
+        "/api/development/v1/semantic-assets"
+      ],
+      "evidence_refs": [
+        "semantic-release://spare_parts/v1"
+      ],
+      "metrics": {
+        "mapped_field_count": 6,
+        "published_code_set_count": 3,
+        "published_metric_count": 3,
+        "published_term_count": 4
+      },
+      "status": "passed",
+      "subsystem": "semantic_governance"
+    },
+    "template_initialization": {
+      "api_refs": [
+        "/api/meta/domain-templates"
+      ],
+      "evidence_refs": [
+        "domain-template://spare_parts/v1"
+      ],
+      "metrics": {
+        "metric_count": 3,
+        "object_type_count": 6,
+        "role_count": 3,
+        "rule_count": 8,
+        "template_version": 1
+      },
+      "status": "passed",
+      "subsystem": "domain_templates"
+    }
+  },
+  "status": "passed",
+  "third_domain_reusable": true
+}

+ 3 - 0
docs/phase2/p2-wp12-spare-parts-package/data/material_master_delta.csv

@@ -0,0 +1,3 @@
+material_code,name,specification,uom_code,category_code,status,updated_at,_operation,_cursor
+MAT-0002,液压润滑油,ISO VG46,L,OIL,ACTIVE,2026-08-02T08:01:00Z,upsert,101
+MAT-0004,密封圈,NBR-40,EA,SEAL,ACTIVE,2026-08-02T08:02:00Z,upsert,102

+ 4 - 0
docs/phase2/p2-wp12-spare-parts-package/data/material_master_snapshot.csv

@@ -0,0 +1,4 @@
+material_code,name,specification,uom_code,category_code,status,updated_at
+MAT-0001,轴承,6205,EA,BEARING,ACTIVE,2026-08-01T08:00:00Z
+MAT-0002,润滑油,ISO VG46,L,OIL,ACTIVE,2026-08-01T08:00:00Z
+MAT-0003,滤芯,HF-100,EA,FILTER,ACTIVE,2026-08-01T08:00:00Z

+ 126 - 0
docs/phase2/p2-wp12-spare-parts-package/domain-template.json

@@ -0,0 +1,126 @@
+{
+  "template_code": "spare_parts",
+  "name": "备品备件/物料主数据",
+  "description": "P2-WP00 默认第二业务域的可导入领域模板;企业真实样本和人员在复制验收前绑定。",
+  "lifecycle_status": "active",
+  "object_types": [
+    {
+      "type_code": "material",
+      "name": "物料主数据",
+      "stable_uid_prefix": "MAT",
+      "source_identity_fields": ["source_system", "material_code"],
+      "fields": [
+        {"code": "material_code", "name": "物料编码", "type": "string", "required": true},
+        {"code": "name", "name": "物料名称", "type": "string", "required": true},
+        {"code": "specification", "name": "规格", "type": "string", "required": false},
+        {"code": "uom_code", "name": "计量单位", "type": "string", "required": true},
+        {"code": "category_code", "name": "分类编码", "type": "string", "required": true},
+        {"code": "status", "name": "生命周期状态", "type": "string", "required": true}
+      ]
+    },
+    {
+      "type_code": "material_category",
+      "name": "物料分类",
+      "stable_uid_prefix": "MCT",
+      "source_identity_fields": ["source_system", "category_code"],
+      "fields": [
+        {"code": "category_code", "name": "分类编码", "type": "string", "required": true},
+        {"code": "name", "name": "分类名称", "type": "string", "required": true},
+        {"code": "parent_code", "name": "上级分类编码", "type": "string", "required": false}
+      ]
+    },
+    {
+      "type_code": "storage_location",
+      "name": "仓库与库位",
+      "stable_uid_prefix": "LOC",
+      "source_identity_fields": ["source_system", "warehouse_code", "location_code"],
+      "fields": [
+        {"code": "warehouse_code", "name": "仓库编码", "type": "string", "required": true},
+        {"code": "location_code", "name": "库位编码", "type": "string", "required": true},
+        {"code": "name", "name": "库位名称", "type": "string", "required": true}
+      ]
+    },
+    {
+      "type_code": "inventory_balance",
+      "name": "库存余额",
+      "stable_uid_prefix": "INV",
+      "source_identity_fields": ["source_system", "material_code", "warehouse_code", "location_code", "snapshot_at"],
+      "fields": [
+        {"code": "material_code", "name": "物料编码", "type": "string", "required": true},
+        {"code": "warehouse_code", "name": "仓库编码", "type": "string", "required": true},
+        {"code": "location_code", "name": "库位编码", "type": "string", "required": true},
+        {"code": "quantity", "name": "库存数量", "type": "number", "required": true},
+        {"code": "snapshot_at", "name": "快照时间", "type": "datetime", "required": true}
+      ]
+    },
+    {
+      "type_code": "supplier_material_mapping",
+      "name": "供应商物料映射",
+      "stable_uid_prefix": "SUM",
+      "source_identity_fields": ["source_system", "supplier_code", "supplier_material_code"],
+      "fields": [
+        {"code": "supplier_code", "name": "供应商编码", "type": "string", "required": true},
+        {"code": "supplier_material_code", "name": "供应商物料编码", "type": "string", "required": true},
+        {"code": "material_code", "name": "平台物料编码", "type": "string", "required": true}
+      ]
+    },
+    {
+      "type_code": "equipment_compatibility",
+      "name": "设备备件适配关系",
+      "stable_uid_prefix": "ECP",
+      "source_identity_fields": ["source_system", "material_code", "equipment_type"],
+      "fields": [
+        {"code": "material_code", "name": "物料编码", "type": "string", "required": true},
+        {"code": "equipment_type", "name": "设备类型", "type": "string", "required": true},
+        {"code": "evidence_ref", "name": "适配证据引用", "type": "string", "required": true}
+      ]
+    }
+  ],
+  "responsibility_roles": [
+    {"code": "material_domain_owner", "name": "物料域负责人", "raci_role": "accountable"},
+    {"code": "material_data_steward", "name": "物料数据管理员", "raci_role": "responsible"},
+    {"code": "phase2_acceptance_owner", "name": "第二阶段验收负责人", "raci_role": "consulted"}
+  ],
+  "rules": [
+    {"code": "QR-MAT-001", "name": "物料编码必填", "dimension": "COMPLETENESS", "target_object": "material"},
+    {"code": "QR-MAT-002", "name": "物料编码全局唯一", "dimension": "UNIQUENESS", "target_object": "material"},
+    {"code": "QR-MAT-003", "name": "物料名称与规格完整", "dimension": "COMPLETENESS", "target_object": "material"},
+    {"code": "QR-MAT-004", "name": "计量单位符合代码集", "dimension": "VALIDITY", "target_object": "material"},
+    {"code": "QR-MAT-005", "name": "物料分类引用有效", "dimension": "REFERENTIAL_INTEGRITY", "target_object": "material"},
+    {"code": "QR-MAT-006", "name": "库存快照新鲜度", "dimension": "FRESHNESS", "target_object": "inventory_balance"},
+    {"code": "QR-MAT-007", "name": "库存数量非负", "dimension": "VALIDITY", "target_object": "inventory_balance"},
+    {"code": "QR-MAT-008", "name": "设备备件适配关系可追溯", "dimension": "TRACEABILITY", "target_object": "equipment_compatibility"}
+  ],
+  "metrics": [
+    {"code": "material_completeness", "name": "物料主数据完整率", "unit": "%"},
+    {"code": "inventory_freshness", "name": "库存快照新鲜率", "unit": "%"},
+    {"code": "responsibility_coverage", "name": "责任覆盖率", "unit": "%"}
+  ],
+  "seed_data": [
+    {
+      "kind": "source_contract",
+      "records": [
+        {"id": "SRC-MATERIAL-POSTGRES", "type": "PostgreSQL", "binding_status": "ENTERPRISE_SAMPLE_REQUIRED"},
+        {"id": "SRC-INVENTORY-MYSQL", "type": "MySQL", "binding_status": "ENTERPRISE_SAMPLE_REQUIRED"},
+        {"id": "SRC-SUPPLIER-FILE", "type": "controlled_file", "binding_status": "ENTERPRISE_SAMPLE_REQUIRED"}
+      ]
+    },
+    {
+      "kind": "term_and_code_set",
+      "records": [
+        {"id": "TERM-MATERIAL-CATEGORY", "name": "物料分类术语与代码集"},
+        {"id": "CODE-UOM", "name": "计量单位代码集"},
+        {"id": "CODE-MATERIAL-STATUS", "name": "物料生命周期状态代码集"},
+        {"id": "TERM-SPARE-PART-CRITICALITY", "name": "备件关键度术语"}
+      ]
+    },
+    {
+      "kind": "workflow_contract",
+      "records": [
+        {"id": "WF-MAT-001", "name": "物料主数据纠错审批"},
+        {"id": "WF-MAT-002", "name": "物料质量问题整改"},
+        {"id": "WF-MAT-003", "name": "术语与代码集变更"}
+      ]
+    }
+  ]
+}

+ 152 - 0
docs/phase2/p2-wp12-spare-parts-package/evidence.json

@@ -0,0 +1,152 @@
+{
+  "schema_version": 1,
+  "package_code": "p2_wp12_spare_parts_v1",
+  "receipts": [
+    {
+      "stage": "template_initialization",
+      "domain_code": "spare_parts",
+      "status": "passed",
+      "subsystem": "domain_templates",
+      "api_refs": ["/api/meta/domain-templates"],
+      "evidence_refs": ["domain-template://spare_parts/v1"],
+      "metrics": {
+        "template_version": 1,
+        "object_type_count": 6,
+        "rule_count": 8,
+        "role_count": 3,
+        "metric_count": 3
+      }
+    },
+    {
+      "stage": "incremental_ingestion",
+      "domain_code": "spare_parts",
+      "status": "passed",
+      "subsystem": "active_metadata",
+      "api_refs": ["/api/meta/active-metadata"],
+      "evidence_refs": ["ingestion://spare_parts/material_master/cursor-102"],
+      "metrics": {
+        "snapshot_rows": 3,
+        "delta_rows": 2,
+        "inserted": 1,
+        "updated": 1,
+        "deleted": 0,
+        "final_rows": 4,
+        "replay_changes": 0,
+        "cursor_before": 100,
+        "cursor_after": 102
+      }
+    },
+    {
+      "stage": "catalog",
+      "domain_code": "spare_parts",
+      "status": "passed",
+      "subsystem": "active_metadata",
+      "api_refs": ["/api/meta/active-metadata"],
+      "evidence_refs": ["catalog-run://spare_parts/batch-002"],
+      "metrics": {
+        "asset_count": 6,
+        "incremental_change_count": 2,
+        "cursor_after": 102
+      }
+    },
+    {
+      "stage": "semantics",
+      "domain_code": "spare_parts",
+      "status": "passed",
+      "subsystem": "semantic_governance",
+      "api_refs": ["/api/development/v1/semantic-assets"],
+      "evidence_refs": ["semantic-release://spare_parts/v1"],
+      "metrics": {
+        "published_term_count": 4,
+        "published_code_set_count": 3,
+        "published_metric_count": 3,
+        "mapped_field_count": 6
+      }
+    },
+    {
+      "stage": "responsibility",
+      "domain_code": "spare_parts",
+      "status": "passed",
+      "subsystem": "unified_responsibilities",
+      "api_refs": ["/api/system/responsibilities"],
+      "evidence_refs": ["responsibility://spare_parts/policy-v1"],
+      "metrics": {
+        "coverage_percent": 100,
+        "bound_role_count": 3
+      }
+    },
+    {
+      "stage": "quality",
+      "domain_code": "spare_parts",
+      "status": "passed",
+      "subsystem": "quality_operations",
+      "api_refs": ["/api/rules/quality-operations"],
+      "evidence_refs": ["quality://spare_parts/run-final"],
+      "metrics": {
+        "published_rule_count": 8,
+        "initial_finding_count": 1,
+        "target_score": 95,
+        "final_score": 100
+      }
+    },
+    {
+      "stage": "remediation",
+      "domain_code": "spare_parts",
+      "status": "passed",
+      "subsystem": "unified_work_center",
+      "api_refs": ["/api/system/work-center"],
+      "evidence_refs": ["workflow://spare_parts/remediation-001"],
+      "metrics": {
+        "issue_status": "closed",
+        "task_status": "completed",
+        "independent_closer": true
+      }
+    },
+    {
+      "stage": "observability",
+      "domain_code": "spare_parts",
+      "status": "passed",
+      "subsystem": "data_observability",
+      "api_refs": ["/api/datafactory/observability"],
+      "evidence_refs": ["incident://spare_parts/freshness-001"],
+      "metrics": {
+        "slo_count": 2,
+        "incident_status": "closed",
+        "recovered": true
+      }
+    },
+    {
+      "stage": "data_product",
+      "domain_code": "spare_parts",
+      "status": "passed",
+      "subsystem": "product_governance",
+      "api_refs": ["/api/dataservice/governance/products"],
+      "evidence_refs": ["product://spare_parts/material-master-v1"],
+      "metrics": {
+        "contract_status": "active",
+        "certificate_status": "issued",
+        "certificate_evidence_refs": [
+          "quality://spare_parts/run-final",
+          "lineage://spare_parts/material-master",
+          "rule://spare_parts/quality-v1",
+          "workflow://spare_parts/remediation-001"
+        ]
+      }
+    },
+    {
+      "stage": "agent",
+      "domain_code": "spare_parts",
+      "status": "passed",
+      "subsystem": "agent_governance",
+      "api_refs": ["/api/knowledge/agents"],
+      "evidence_refs": ["agent-request://spare_parts/read-001"],
+      "metrics": {
+        "autonomy_level": "read_only",
+        "decision": "authorized",
+        "automatic_execution_allowed": false,
+        "citation_count": 2,
+        "cross_domain_denied": true
+      }
+    }
+  ]
+}

+ 81 - 0
docs/phase2/p2-wp12-spare-parts-package/manifest.json

@@ -0,0 +1,81 @@
+{
+  "schema_version": 1,
+  "package_code": "p2_wp12_spare_parts_v1",
+  "domain": {
+    "code": "spare_parts",
+    "name": "备品备件/物料主数据"
+  },
+  "files": {
+    "template": {
+      "path": "domain-template.json",
+      "sha256": "4619db1472762c5de81c3fa0510040ceaca2f90ef8308c89ea17ca8af1e9b9ad"
+    },
+    "evidence": {
+      "path": "evidence.json",
+      "sha256": "986667e25075083e677be509b7da8b71ab7017cc96bcfd09c0e55677b9e6dc52"
+    },
+    "snapshot": {
+      "path": "data/material_master_snapshot.csv",
+      "sha256": "2a9e440d952335cd9c0bb9bc7e024b5431848a873e8a658027933eec40aa5216"
+    },
+    "delta": {
+      "path": "data/material_master_delta.csv",
+      "sha256": "561a372016ed79a0b261042afebd420dc12af2e27e21cf7414cb0404d2a9e922"
+    },
+    "acceptance_report": {
+      "path": "acceptance-report.json",
+      "sha256": "3fb4d6d66cfcfb8887586d691210eefb5b3cb4e9026a1381fb03fad35b4de036"
+    }
+  },
+  "source": {
+    "kind": "controlled_csv",
+    "classification": "desensitized",
+    "primary_key": ["material_code"],
+    "required_columns": [
+      "material_code",
+      "name",
+      "uom_code",
+      "category_code",
+      "status",
+      "updated_at"
+    ],
+    "operation_field": "_operation",
+    "cursor_field": "_cursor",
+    "snapshot_cursor": 100
+  },
+  "core_change_assessment": {
+    "baseline_commit": "8ec8caa",
+    "device_specific_changes": [],
+    "generic_extensions": [
+      "app/core/governance/domain_replication.py",
+      "scripts/validate_domain_replication.py"
+    ],
+    "extension_points_used": [
+      "domain_templates",
+      "active_metadata",
+      "semantic_governance",
+      "unified_responsibilities",
+      "quality_operations",
+      "unified_work_center",
+      "data_observability",
+      "product_governance",
+      "agent_governance"
+    ]
+  },
+  "enterprise_bindings": [
+    {"kind": "domain_owner", "status": "unbound"},
+    {"kind": "data_steward", "status": "unbound"},
+    {"kind": "readonly_source", "status": "unbound"},
+    {"kind": "uat_users", "status": "unbound"}
+  ],
+  "third_domain_reuse": {
+    "reusable": true,
+    "required_replacements": [
+      "domain_template",
+      "source_snapshot_and_delta",
+      "responsibility_bindings",
+      "semantic_quality_and_workflow_configuration",
+      "product_contract_and_agent_scope"
+    ]
+  }
+}

+ 50 - 0
docs/runbooks/P2_WP12_THIRD_DOMAIN_REPLICATION_GUIDE.md

@@ -0,0 +1,50 @@
+# P2-WP12 第三业务域复制实施指南
+
+## 1. 适用范围
+
+本指南用于在不复制设备域或物料域服务的前提下,将 P2-WP12 实施包复用到第三业务域。
+复制操作应在独立目录和隔离环境完成,不得直接覆盖已验收的第二业务域包。
+
+## 2. 准备门禁
+
+开始前必须取得:业务域范围与 Owner、Data Steward 和 UAT 用户;经批准的只读数据源、
+字段字典、主键与增量游标;正式术语/代码集/指标;质量阈值、整改时限和 SLO;数据产品
+合同及 Agent 允许的 API/MCP、动作和证据要求。秘密只保存于企业密钥系统,不写实施包。
+
+## 3. 复制步骤
+
+1. 复制 `docs/phase2/p2-wp12-spare-parts-package/` 到新的领域目录,修改包编码和领域编码;
+2. 用领域模板初始化对象类型、责任角色、术语/代码集、规则、指标和任务配置;
+3. 生成最小脱敏快照与严格单调的增量样本,声明主键、必填列、操作字段和游标字段;
+4. 通过通用接口建立目录、语义、责任、质量、整改、可观测、数据产品和 Agent 证据;
+5. 为每个阶段填写 `passed` 收据、通用 API 引用、证据引用和可验证指标;
+6. 更新 Manifest 文件摘要,记录相对当前基线的通用扩展和设备专用代码改动;
+7. 执行领域复制验证器,两次输出必须字节一致,增量重放必须为 0 变更;
+8. 在企业隔离 PostgreSQL 和批准的数据源上执行定向集成及端到端 UAT;
+9. 归档验收报告、缺陷清单、回滚证据和签认结果,由阶段验收负责人批准。
+
+## 4. 验证命令
+
+```bash
+.venv/bin/python scripts/validate_domain_replication.py \
+  --package-dir <第三业务域实施包目录> \
+  --output <隔离输出目录>/acceptance-report.json
+```
+
+验证报告只允许包含计数、游标、摘要和规范化证据引用。若输出出现源数据值、令牌、密码、
+连接串或其他秘密,应立即停止验收、隔离产物并重新执行脱敏与密钥处置。
+
+## 5. 平台化缺口处理
+
+- 能用模板或配置表达的差异,留在领域实施包;
+- 确需通用扩展时,先定义跨领域契约和失败关闭规则,再修改平台核心;
+- 若需要调用或修改设备/物料专用服务,应登记为平台化缺口,不能隐藏在领域代码中;
+- 任何通用扩展都必须补充独立测试、发布副本同步、迁移/回滚说明和现有领域兼容验证;
+- 不得为了让报告通过而伪造企业负责人、真实来源、告警回执或用户验收。
+
+## 6. 完成标准
+
+十个阶段全部通过、设备专用核心改动为 0、相同增量重放为 0 变更、数据产品合同与合格证
+证据完整、Agent 只读/建议场景受授权且跨域拒绝,并由企业真实责任人完成 UAT 后,第三
+业务域复制才可标记为业务验收完成。仅通过本地样本时,成熟度应记录为“工程完成,待企业
+数据与业务验收”。

+ 51 - 0
docs/validation/P2_WP12_SECOND_DOMAIN_REPLICATION_EVIDENCE.md

@@ -0,0 +1,51 @@
+# P2-WP12 第二业务域复制定向验证证据
+
+## 1. 证据结论
+
+P2-WP12 的本地工程门禁通过。默认第二业务域“备品备件/物料主数据”通过一个自包含、
+摘要绑定的实施包完成十阶段治理证据验证;第二业务域没有引入设备域 API,也没有修改
+设备域专用服务。结论仅证明当前分支具备进入企业 UAT 的工程条件。
+
+## 2. 实施包验证结果
+
+验证器对 `docs/phase2/p2-wp12-spare-parts-package/` 生成确定性报告:
+
+| 证据 | 结果 |
+|---|---|
+| 阶段状态 | 10/10 `passed` |
+| 快照与增量 | 3 行快照、2 行增量;新增 1、更新 1、删除 0、最终 4 |
+| 增量游标 | 100 -> 102 |
+| 幂等重放 | `replay_changes=0` |
+| 设备专用核心改动 | 0 |
+| 第三领域复用标志 | `true` |
+| 报告内部摘要 | `fd366d5ceec8142e145c0170dbe22766fd47f403ec3ec871b7027ac942a621df` |
+| 报告文件摘要 | `3fb4d6d66cfcfb8887586d691210eefb5b3cb4e9026a1381fb03fad35b4de036` |
+| 企业 UAT | `blocked_external` |
+
+企业外部门禁为 `data_steward`、`domain_owner`、`readonly_source` 和 `uat_users`。报告不含
+源数据行,Manifest 同时校验模板、证据、快照、增量和验收报告文件摘要。
+
+## 3. 定向测试
+
+本工作包遵循“只测试变动及直接依赖范围”的约束,没有执行全量回归:
+
+- WP12 核心与实施包契约:12 项通过;
+- 领域模板、主动元数据、语义、责任、质量、工作中心、可观测、数据产品和 Agent 九组
+  直接依赖核心测试:58 项通过;
+- 上述九个子系统的真实 PostgreSQL 事务隔离集成测试:9 项通过;
+- 发布副本一致性、OpenAPI 可复现性和变更格式检查:通过。
+
+真实 PostgreSQL 验证证明九个既有子系统可在数据库约束下分别工作;实施包的十阶段收据
+证明它们可按同一业务域证据契约组合。两者不是企业真实来源上的单次端到端业务 UAT,
+不能据此宣称真实数据接入、企业责任绑定、正式告警送达或生产验收已经完成。
+
+## 4. 失败关闭场景
+
+定向测试覆盖:阶段缺失/失败、游标回退或重复、秘密字段、产品合格证证据缺失、Agent
+无引用或越权、跨域未拒绝、设备专用 API、设备专用服务改动和文件摘要不一致。任一条件
+出现时,复制包不得输出 `passed`。
+
+## 5. 后续验收
+
+P2-WP13 应在企业隔离环境替换真实责任人、只读来源和正式业务配置,保留同一阶段编号、
+证据字段、摘要绑定和失败关闭规则,完成真实用户 UAT、缺陷关闭、培训、迁移与回滚演练。

+ 113 - 0
scripts/validate_domain_replication.py

@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Validate a self-contained domain replication implementation package."""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import json
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+    sys.path.insert(0, str(ROOT))
+
+from app.core.governance.domain_replication import (  # noqa: E402
+    DomainReplicationError,
+    evaluate_replication_package,
+)
+
+_SHA256 = re.compile(r"^[0-9a-f]{64}$")
+
+
+def _sha256(path: Path) -> str:
+    return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def _json(path: Path) -> dict:
+    value = json.loads(path.read_text(encoding="utf-8"))
+    if not isinstance(value, dict):
+        raise DomainReplicationError(f"JSON object required: {path}")
+    return value
+
+
+def _resolve(package_dir: Path, relative: str) -> Path:
+    path = (package_dir / relative).resolve()
+    if package_dir not in path.parents:
+        raise DomainReplicationError(f"package path escapes package directory: {relative}")
+    if not path.is_file():
+        raise DomainReplicationError(f"package file does not exist: {relative}")
+    return path
+
+
+def _rows(path: Path) -> list[dict[str, str]]:
+    with path.open(encoding="utf-8", newline="") as handle:
+        return list(csv.DictReader(handle))
+
+
+def validate_package(package_dir: Path) -> dict:
+    package_dir = package_dir.resolve()
+    manifest = _json(package_dir / "manifest.json")
+    files = manifest.get("files")
+    if not isinstance(files, dict):
+        raise DomainReplicationError("manifest.files must be an object")
+    resolved: dict[str, Path] = {}
+    required_files = ("template", "evidence", "snapshot", "delta")
+    optional_files = ("acceptance_report",)
+    for key in (*required_files, *optional_files):
+        binding = files.get(key)
+        if key in optional_files and binding is None:
+            continue
+        if not isinstance(binding, dict):
+            raise DomainReplicationError(f"manifest.files.{key} is required")
+        path = _resolve(package_dir, str(binding.get("path", "")))
+        expected = str(binding.get("sha256", ""))
+        if not _SHA256.fullmatch(expected):
+            raise DomainReplicationError(f"manifest.files.{key}.sha256 is invalid")
+        actual = _sha256(path)
+        if actual != expected:
+            raise DomainReplicationError(
+                f"package file digest mismatch for {key}: expected={expected}, actual={actual}"
+            )
+        resolved[key] = path
+    report = evaluate_replication_package(
+        manifest,
+        _json(resolved["evidence"]),
+        template=_json(resolved["template"]),
+        snapshot_rows=_rows(resolved["snapshot"]),
+        delta_rows=_rows(resolved["delta"]),
+    )
+    if "acceptance_report" in resolved:
+        rendered = json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
+        if resolved["acceptance_report"].read_text(encoding="utf-8") != rendered:
+            raise DomainReplicationError(
+                "acceptance report does not match the deterministic package result"
+            )
+    return report
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--package-dir", type=Path, required=True)
+    parser.add_argument("--output", default="-")
+    args = parser.parse_args(argv)
+    report = validate_package(args.package_dir)
+    rendered = json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
+    if args.output == "-":
+        sys.stdout.write(rendered)
+    else:
+        output = Path(args.output)
+        output.parent.mkdir(parents=True, exist_ok=True)
+        output.write_text(rendered, encoding="utf-8")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        raise SystemExit(main())
+    except (DomainReplicationError, OSError, ValueError, json.JSONDecodeError) as exc:
+        print(f"ERROR: {exc}", file=sys.stderr)
+        raise SystemExit(2) from exc

+ 167 - 0
tests/acceptance/test_p2_wp12_second_domain_replication.py

@@ -0,0 +1,167 @@
+from __future__ import annotations
+
+import csv
+import importlib.util
+import json
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+PACKAGE_DIR = ROOT / "docs/phase2/p2-wp12-spare-parts-package"
+MODULE_PATH = ROOT / "app/core/governance/domain_replication.py"
+SPEC = importlib.util.spec_from_file_location("domain_replication", MODULE_PATH)
+assert SPEC and SPEC.loader
+domain_replication = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(domain_replication)
+
+
+def _json(name: str) -> dict:
+    return json.loads((PACKAGE_DIR / name).read_text(encoding="utf-8"))
+
+
+def _rows(name: str) -> list[dict[str, str]]:
+    with (PACKAGE_DIR / "data" / name).open(encoding="utf-8", newline="") as handle:
+        return list(csv.DictReader(handle))
+
+
+def _accept(manifest=None, evidence=None):
+    return domain_replication.evaluate_replication_package(
+        manifest or _json("manifest.json"),
+        evidence or _json("evidence.json"),
+        template=_json("domain-template.json"),
+        snapshot_rows=_rows("material_master_snapshot.csv"),
+        delta_rows=_rows("material_master_delta.csv"),
+    )
+
+
+def test_second_domain_package_completes_all_platform_stages_without_raw_rows():
+    report = _accept()
+
+    assert report["status"] == "passed"
+    assert report["domain"]["code"] == "spare_parts"
+    assert report["enterprise_uat"]["status"] == "blocked_external"
+    assert report["third_domain_reusable"] is True
+    assert report["device_specific_core_changes"] == 0
+    assert set(report["stages"]) == set(domain_replication.REQUIRED_STAGES)
+    assert all(item["status"] == "passed" for item in report["stages"].values())
+    serialized = json.dumps(report, ensure_ascii=False)
+    assert "MAT-0001" not in serialized
+    assert "轴承" not in serialized
+
+
+def test_controlled_source_snapshot_and_delta_are_incremental_and_idempotent():
+    report = _accept()["incremental_collection"]
+
+    assert report["source_kind"] == "controlled_csv"
+    assert report["classification"] == "desensitized"
+    assert report["snapshot_rows"] == 3
+    assert report["delta_rows"] == 2
+    assert report["inserted"] == 1
+    assert report["updated"] == 1
+    assert report["deleted"] == 0
+    assert report["final_rows"] == 4
+    assert report["replay_changes"] == 0
+    assert report["cursor_before"] == 100
+    assert report["cursor_after"] == 102
+    assert len(report["final_digest"]) == 64
+
+
+def test_missing_or_failed_capability_evidence_blocks_acceptance():
+    evidence = _json("evidence.json")
+    evidence["receipts"] = [
+        item for item in evidence["receipts"] if item["stage"] != "data_product"
+    ]
+    with pytest.raises(ValueError, match="missing stages.*data_product"):
+        _accept(evidence=evidence)
+
+    evidence = _json("evidence.json")
+    quality = next(
+        item for item in evidence["receipts"] if item["stage"] == "quality"
+    )
+    quality["status"] = "failed"
+    with pytest.raises(ValueError, match="quality.*passed"):
+        _accept(evidence=evidence)
+
+    manifest = _json("manifest.json")
+    manifest["enterprise_bindings"] = manifest["enterprise_bindings"][:-1]
+    with pytest.raises(ValueError, match="missing enterprise bindings.*uat_users"):
+        _accept(manifest=manifest)
+
+
+def test_quality_remediation_observability_product_and_agent_are_closed_loop():
+    report = _accept()
+    quality = report["stages"]["quality"]["metrics"]
+    remediation = report["stages"]["remediation"]["metrics"]
+    observability = report["stages"]["observability"]["metrics"]
+    product = report["stages"]["data_product"]["metrics"]
+    agent = report["stages"]["agent"]["metrics"]
+
+    assert quality["published_rule_count"] >= 5
+    assert quality["final_score"] >= quality["target_score"]
+    assert remediation == {
+        "issue_status": "closed",
+        "task_status": "completed",
+        "independent_closer": True,
+    }
+    assert observability["incident_status"] == "closed"
+    assert observability["recovered"] is True
+    assert product["contract_status"] == "active"
+    assert product["certificate_status"] == "issued"
+    assert agent["autonomy_level"] in {"read_only", "suggestion"}
+    assert agent["decision"] == "authorized"
+    assert agent["automatic_execution_allowed"] is False
+    assert agent["citation_count"] >= 1
+    assert agent["cross_domain_denied"] is True
+
+
+def test_device_specific_changes_and_device_api_evidence_are_rejected():
+    manifest = _json("manifest.json")
+    manifest["core_change_assessment"]["device_specific_changes"] = [
+        "app/core/data_research/device_assets.py"
+    ]
+    with pytest.raises(ValueError, match="device-specific"):
+        _accept(manifest=manifest)
+
+    evidence = _json("evidence.json")
+    evidence["receipts"][0]["api_refs"] = [
+        "/api/development/v1/device-semantics/bootstrap"
+    ]
+    with pytest.raises(ValueError, match="device-specific API"):
+        _accept(evidence=evidence)
+
+
+def test_secret_like_source_columns_and_non_monotonic_cursors_are_rejected():
+    snapshot = _rows("material_master_snapshot.csv")
+    snapshot[0]["api_token"] = "not-allowed"
+    with pytest.raises(ValueError, match="secret-like source field"):
+        domain_replication.collect_incremental_rows(
+            snapshot,
+            _rows("material_master_delta.csv"),
+            _json("manifest.json")["source"],
+        )
+
+    delta = _rows("material_master_delta.csv")
+    delta[1]["_cursor"] = "100"
+    with pytest.raises(ValueError, match="strictly increase"):
+        domain_replication.collect_incremental_rows(
+            _rows("material_master_snapshot.csv"),
+            delta,
+            _json("manifest.json")["source"],
+        )
+
+
+def test_product_certificate_and_agent_citations_must_bind_canonical_evidence():
+    evidence = _json("evidence.json")
+    product = next(
+        item for item in evidence["receipts"] if item["stage"] == "data_product"
+    )
+    product["metrics"]["certificate_evidence_refs"] = []
+    with pytest.raises(ValueError, match="certificate evidence"):
+        _accept(evidence=evidence)
+
+    evidence = _json("evidence.json")
+    agent = next(item for item in evidence["receipts"] if item["stage"] == "agent")
+    agent["metrics"]["citation_count"] = 0
+    with pytest.raises(ValueError, match="citation"):
+        _accept(evidence=evidence)

+ 128 - 0
tests/test_p2_wp12_replication_package_contract.py

@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+PACKAGE_DIR = ROOT / "docs/phase2/p2-wp12-spare-parts-package"
+BASE_COMMIT = "8ec8caa"
+
+
+def _sha256(path: Path) -> str:
+    return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def test_replication_package_is_self_contained_and_hash_bound():
+    manifest = json.loads((PACKAGE_DIR / "manifest.json").read_text(encoding="utf-8"))
+
+    for key in ("template", "evidence", "snapshot", "delta", "acceptance_report"):
+        binding = manifest["files"][key]
+        path = PACKAGE_DIR / binding["path"]
+        assert path.is_file()
+        assert _sha256(path) == binding["sha256"]
+    assert (PACKAGE_DIR / "domain-template.json").read_bytes() == (
+        ROOT / "docs/phase2/P2_WP01_SPARE_PARTS_DOMAIN_TEMPLATE.json"
+    ).read_bytes()
+
+
+def test_cli_report_is_reproducible_and_contains_no_source_rows(tmp_path: Path):
+    outputs = [tmp_path / "first.json", tmp_path / "second.json"]
+    for output in outputs:
+        completed = subprocess.run(
+            [
+                str(ROOT / ".venv/bin/python"),
+                str(ROOT / "scripts/validate_domain_replication.py"),
+                "--package-dir",
+                str(PACKAGE_DIR),
+                "--output",
+                str(output),
+            ],
+            cwd=ROOT,
+            capture_output=True,
+            text=True,
+        )
+        assert completed.returncode == 0, completed.stderr
+
+    assert outputs[0].read_bytes() == outputs[1].read_bytes()
+    assert outputs[0].read_bytes() == (PACKAGE_DIR / "acceptance-report.json").read_bytes()
+    report = json.loads(outputs[0].read_text(encoding="utf-8"))
+    assert report["status"] == "passed"
+    assert report["report_sha256"]
+    serialized = outputs[0].read_text(encoding="utf-8")
+    assert "MAT-0001" not in serialized
+    assert "轴承" not in serialized
+
+
+def test_package_uses_generic_platform_apis_and_documents_external_gates():
+    evidence = json.loads((PACKAGE_DIR / "evidence.json").read_text(encoding="utf-8"))
+    api_refs = {
+        ref
+        for receipt in evidence["receipts"]
+        for ref in receipt["api_refs"]
+    }
+    required_prefixes = {
+        "/api/meta/domain-templates",
+        "/api/meta/active-metadata",
+        "/api/development/v1/semantic-assets",
+        "/api/rules/quality-operations",
+        "/api/system/work-center",
+        "/api/datafactory/observability",
+        "/api/dataservice/governance/products",
+        "/api/knowledge/agents",
+    }
+    assert required_prefixes <= api_refs
+    assert all("/device-" not in ref for ref in api_refs)
+
+    readme = (PACKAGE_DIR / "README.md").read_text(encoding="utf-8")
+    for phrase in (
+        "企业真实负责人",
+        "企业只读数据源",
+        "真实用户 UAT",
+        "不能替代生产验收",
+        "第三业务域",
+    ):
+        assert phrase in readme
+
+
+def test_wp12_changes_do_not_modify_device_specific_services():
+    completed = subprocess.run(
+        ["git", "diff", "--name-only", BASE_COMMIT, "--"],
+        cwd=ROOT,
+        check=True,
+        capture_output=True,
+        text=True,
+    )
+    changed = set(completed.stdout.splitlines())
+    status = subprocess.run(
+        ["git", "status", "--porcelain"],
+        cwd=ROOT,
+        check=True,
+        capture_output=True,
+        text=True,
+    )
+    changed.update(
+        line[3:]
+        for line in status.stdout.splitlines()
+        if line.startswith("?? ")
+    )
+    forbidden_prefixes = (
+        "app/core/data_research/device_",
+        "app/api/data_development/device_",
+        "frontend/src/views/dataResearch/device",
+    )
+    assert not any(path.startswith(forbidden_prefixes) for path in changed)
+    assert "app/core/governance/domain_replication.py" in changed
+
+
+def test_replication_validator_has_no_domain_specific_imports():
+    source = (ROOT / "app/core/governance/domain_replication.py").read_text(
+        encoding="utf-8"
+    )
+    cli = (ROOT / "scripts/validate_domain_replication.py").read_text(
+        encoding="utf-8"
+    )
+    assert "app.core.data_research.device" not in source + cli
+    assert "spare_parts" not in source
+    assert "material_code" not in source