Browse Source

feat: establish product engineering delivery controls

马小龙 2 weeks ago
parent
commit
8ec8caa093

+ 10 - 0
deployment/CHECKLIST.md

@@ -66,6 +66,16 @@
 - [ ] 已准备新的恢复项目名、独立端口和网络
 - [ ] 知晓回滚不执行数据库 downgrade,而是用上一版镜像包和匹配备份恢复候选项目
 
+## P2-WP11 产品交付门禁
+
+- [ ] 开发、测试、预生产配置差异检查通过,项目、网络、数据命名空间和端口无碰撞
+- [ ] 目标环境六类就绪报告为 `ready`,且没有敏感凭据出现在报告中
+- [ ] 晋级对象绑定版本、SHA-256、审批、测试证据和回滚引用,并通过包摘要复核
+- [ ] 客户迁移 dry-run 的映射完整、增量滞后为 0、记录数/摘要一致、回滚已准备
+- [ ] 离线包包含 OpenAPI、前后端 CycloneDX SBOM、版本清单和全文件校验值
+- [ ] 正式构建未使用 `--skip-images`,最终镜像与归档已签名并进入企业制品库
+- [ ] 预生产和生产 TLS、密钥、域名、监控、告警、值班及变更审批已落实
+
 ## WP13 恢复验收
 
 - [ ] 恢复目标项目与源项目名称不同

+ 5 - 0
deployment/MANIFEST.md

@@ -54,6 +54,8 @@
 | `sync_release.sh` | 从仓库根目录同步最新代码到 deployment/ |
 | `package_release.sh` | 打包 `dist/dataops-platform-release-YYYYMMDD.tar.gz` |
 | `compose/package_offline.sh` | 生成含默认镜像、版本清单和 SHA-256 的 Compose 离线包 |
+| `compose/product_engineering.py` | 环境、就绪、晋级、迁移、版本清单和 SBOM 控制面 |
+| `compose/reproducible_archive.py` | 生成规范时间戳、属主、权限和顺序的可复现归档 |
 
 ## WP13 Compose 运维
 
@@ -65,6 +67,7 @@
 | `compose/restore.sh` | 精确目标确认、同名拒绝、先验签和新项目恢复 |
 | `compose/rollback.sh` | 使用上一版离线包和升级前备份恢复候选项目 |
 | `compose/.env.rehearsal.example` | 隔离恢复演练的端口和网络模板 |
+| `environments/` | 开发、测试和预生产隔离配置及差异检查输入 |
 | `compose/README.md` | 离线交付、备份、恢复、回滚和企业门禁 |
 
 ## 文档
@@ -92,6 +95,8 @@
 - 默认应用镜像使用稳定离线标签,全部对外端口支持恢复项目隔离覆盖
 - Compose 离线包包含默认 profile 镜像、版本清单和全文件 SHA-256
 - 备份恢复默认失败关闭、禁止同项目原位恢复、禁止数据库 downgrade
+- 开发/测试/预生产隔离配置、六类就绪监控、受控晋级和客户迁移 dry-run
+- 固定构建时间的可复现离线归档,以及 OpenAPI、SBOM、迁移和源码互相追溯
 
 ## 历史重要变更(2026-05)
 

+ 11 - 4
deployment/compose/README.md

@@ -1,4 +1,4 @@
-# WP13 Compose 离线交付、备份与恢复
+# WP13 / P2-WP11 Compose 产品交付、备份与恢复
 
 本目录提供首期示范环境的可执行交付链。它以
 `deploy/docker/docker-compose.yml` 为唯一 Compose 定义,不建立第二套人工维护的
@@ -15,7 +15,8 @@ bash deployment/compose/package_offline.sh --version <发布版本>
 ```
 
 产物位于 `dist/dataops-platform-offline-<发布版本>.tar.gz`,包含默认 profile
-所需镜像、Compose 配置、数据库迁移、安装脚本、版本清单和全文件 SHA-256。
+所需镜像、Compose 配置、数据库迁移、三环境配置、OpenAPI、CycloneDX SBOM、
+可追溯版本清单和全文件 SHA-256。固定 `--source-date-epoch` 时,相同输入生成相同归档。
 可选的 MCP、LightRAG shadow profile 不属于示范版默认启动范围,应另行生成并验收
 扩展镜像包。
 
@@ -28,8 +29,7 @@ cp deploy/docker/.env.example deploy/docker/.env.local
 # 通过企业批准的密钥渠道填写 .env.local,不在终端或工单中粘贴密钥。
 bash operations/install_offline.sh \
   --bundle-root "$PWD" \
-  --env-file "$PWD/deploy/docker/.env.local" \
-  --project-name dataops-test
+  --environment-profile environments/test.json
 ```
 
 安装顺序固定为:校验全部文件、载入镜像、解析 Compose、检查镜像、启动
@@ -87,3 +87,10 @@ bash <上一版目录>/operations/rollback.sh \
 
 本流程不会执行 Alembic downgrade。正式生产切换、旧项目下线和备份删除均需要企业
 变更审批,不由脚本自动执行。
+
+## 6. P2-WP11 产品工程控制
+
+`product_engineering.py` 提供环境差异、Compose 六类就绪采集、治理对象晋级包验证、
+客户迁移 dry-run、版本清单和 SBOM 生成。详细输入契约和操作步骤见
+`docs/runbooks/P2_WP11_PRODUCT_DELIVERY_RUNBOOK.md`。晋级和迁移命令只生成/验证证据,
+不自动修改目标环境或客户源系统。

+ 30 - 1
deployment/compose/install_offline.sh

@@ -15,6 +15,8 @@ Verify, load, and start a DataOps offline Compose bundle.
 Options:
   --bundle-root PATH    Extracted bundle root (default: parent of operations/).
   --env-file PATH       Optional environment file.
+  --environment-profile PATH
+                        Validated JSON profile; selects matching .env and project.
   --project-name NAME   Compose project (default: dataops-test).
   --timeout SECONDS     Health timeout (default: 600).
   --help                Show this help.
@@ -23,13 +25,15 @@ EOF
 
 BUNDLE_ROOT="$(wp13_default_root)"
 ENV_FILE=""
-PROJECT_NAME="dataops-test"
+ENVIRONMENT_PROFILE=""
+PROJECT_NAME=""
 TIMEOUT_SECONDS=600
 
 while [[ $# -gt 0 ]]; do
   case "$1" in
     --bundle-root) BUNDLE_ROOT="$2"; shift 2 ;;
     --env-file) ENV_FILE="$2"; shift 2 ;;
+    --environment-profile) ENVIRONMENT_PROFILE="$2"; shift 2 ;;
     --project-name) PROJECT_NAME="$2"; shift 2 ;;
     --timeout) TIMEOUT_SECONDS="$2"; shift 2 ;;
     --help|-h) usage; exit 0 ;;
@@ -40,6 +44,23 @@ done
 BUNDLE_ROOT="$(cd "${BUNDLE_ROOT}" && pwd)"
 COMPOSE_FILE="${BUNDLE_ROOT}/deploy/docker/docker-compose.yml"
 
+if [[ -n "${ENVIRONMENT_PROFILE}" ]]; then
+  if [[ "${ENVIRONMENT_PROFILE}" != /* ]]; then
+    ENVIRONMENT_PROFILE="${BUNDLE_ROOT}/${ENVIRONMENT_PROFILE}"
+  fi
+  [[ -r "${ENVIRONMENT_PROFILE}" ]] \
+    || wp13_die "environment profile is not readable: ${ENVIRONMENT_PROFILE}"
+  wp13_require_command python3
+  PROFILE_PROJECT="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["project_name"])' "${ENVIRONMENT_PROFILE}")"
+  PROFILE_ENV_FILE="${ENVIRONMENT_PROFILE%.json}.env"
+  if [[ -n "${PROJECT_NAME}" && "${PROJECT_NAME}" != "${PROFILE_PROJECT}" ]]; then
+    wp13_die "project name does not match environment profile: ${PROJECT_NAME} != ${PROFILE_PROJECT}"
+  fi
+  PROJECT_NAME="${PROFILE_PROJECT}"
+  ENV_FILE="${PROFILE_ENV_FILE}"
+fi
+PROJECT_NAME="${PROJECT_NAME:-dataops-test}"
+
 wp13_verify_checksums "${BUNDLE_ROOT}"
 wp13_require_command docker
 if [[ -f "${BUNDLE_ROOT}/images/dataops-images.tar" ]]; then
@@ -55,6 +76,7 @@ preflight_args=(
   --project-name "${PROJECT_NAME}"
 )
 [[ -z "${ENV_FILE}" ]] || preflight_args+=(--env-file "${ENV_FILE}")
+[[ -z "${ENVIRONMENT_PROFILE}" ]] || preflight_args+=(--environment-profile "${ENVIRONMENT_PROFILE}")
 bash "${SCRIPT_DIR}/preflight.sh" "${preflight_args[@]}"
 
 wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" \
@@ -73,7 +95,14 @@ head="$(
 )"
 [[ -n "${current}" && "${current}" == "${head}" ]] \
   || wp13_die "post-install migration mismatch: current=${current:-unknown}, head=${head:-unknown}"
+expected_head="$(wp13_expected_migration_head "${BUNDLE_ROOT}")"
+[[ -n "${expected_head}" ]] \
+  || wp13_die "release manifest migration head is missing"
+[[ "${head}" == "${expected_head}" ]] \
+  || wp13_die "installed image migration head does not match release manifest: image=${head}, release=${expected_head}"
 
 cp "${BUNDLE_ROOT}/release-manifest.txt" \
   "${BUNDLE_ROOT}/installed-${PROJECT_NAME}.manifest"
+cp "${BUNDLE_ROOT}/release-manifest.json" \
+  "${BUNDLE_ROOT}/installed-${PROJECT_NAME}.manifest.json"
 wp13_log "offline install completed at migration ${current}"

+ 36 - 10
deployment/compose/package_offline.sh

@@ -15,7 +15,8 @@ Build a checksum-verifiable DataOps Compose offline archive.
 Options:
   --repo-root PATH      Repository root.
   --output-dir PATH     Destination directory (default: <repo>/dist).
-  --version VALUE       Package version (default: UTC timestamp).
+  --version VALUE       Package version (default: source commit UTC timestamp).
+  --source-date-epoch N Reproducible build timestamp (default: source commit time).
   --skip-images         Contract-test mode; omit Docker image archive.
   --help                Show this help.
 EOF
@@ -23,7 +24,8 @@ EOF
 
 REPO_ROOT="$(wp13_default_root)"
 OUTPUT_DIR=""
-VERSION="$(date -u '+%Y%m%dT%H%M%SZ')"
+VERSION=""
+SOURCE_DATE_EPOCH=""
 SKIP_IMAGES=0
 
 while [[ $# -gt 0 ]]; do
@@ -31,6 +33,7 @@ while [[ $# -gt 0 ]]; do
     --repo-root) REPO_ROOT="$2"; shift 2 ;;
     --output-dir) OUTPUT_DIR="$2"; shift 2 ;;
     --version) VERSION="$2"; shift 2 ;;
+    --source-date-epoch) SOURCE_DATE_EPOCH="$2"; shift 2 ;;
     --skip-images) SKIP_IMAGES=1; shift ;;
     --help|-h) usage; exit 0 ;;
     *) wp13_die "unknown argument: $1" ;;
@@ -41,12 +44,19 @@ REPO_ROOT="$(cd "${REPO_ROOT}" && pwd)"
 OUTPUT_DIR="${OUTPUT_DIR:-${REPO_ROOT}/dist}"
 mkdir -p "${OUTPUT_DIR}"
 OUTPUT_DIR="$(cd "${OUTPUT_DIR}" && pwd)"
+wp13_require_command python3
+SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "${REPO_ROOT}" show -s --format=%ct HEAD 2>/dev/null || printf 0)}"
+[[ "${SOURCE_DATE_EPOCH}" =~ ^[0-9]+$ ]] \
+  || wp13_die "--source-date-epoch must be a non-negative integer"
+VERSION="${VERSION:-$(python3 -c 'import datetime,sys; print(datetime.datetime.fromtimestamp(int(sys.argv[1]), datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ"))' "${SOURCE_DATE_EPOCH}")}"
 
 for required in \
   deploy/docker/docker-compose.yml \
   deploy/docker/.env.example \
   database \
   migrations \
+  deployment/environments \
+  docs/architecture/OPENAPI.yaml \
   deployment/compose; do
   [[ -e "${REPO_ROOT}/${required}" ]] \
     || wp13_die "required release input is missing: ${required}"
@@ -72,13 +82,17 @@ BUNDLE_ROOT="${TEMP_DIR}/${PACKAGE_NAME}"
 
 mkdir -p \
   "${BUNDLE_ROOT}/deploy" \
+  "${BUNDLE_ROOT}/environments" \
   "${BUNDLE_ROOT}/operations" \
-  "${BUNDLE_ROOT}/images"
+  "${BUNDLE_ROOT}/images" \
+  "${BUNDLE_ROOT}/trace"
 cp -R "${REPO_ROOT}/deploy/docker" "${BUNDLE_ROOT}/deploy/docker"
 cp -R "${REPO_ROOT}/database" "${BUNDLE_ROOT}/database"
 cp -R "${REPO_ROOT}/migrations" "${BUNDLE_ROOT}/migrations"
+cp -R "${REPO_ROOT}/deployment/environments/." "${BUNDLE_ROOT}/environments/"
 cp -R "${REPO_ROOT}/deployment/compose/." "${BUNDLE_ROOT}/operations/"
 cp "${REPO_ROOT}/deployment/MANIFEST.md" "${BUNDLE_ROOT}/MANIFEST.md"
+cp "${REPO_ROOT}/docs/architecture/OPENAPI.yaml" "${BUNDLE_ROOT}/trace/OPENAPI.yaml"
 
 find "${BUNDLE_ROOT}" -type d -name __pycache__ -prune -exec rm -rf {} +
 find "${BUNDLE_ROOT}" -type f -name '*.pyc' -delete
@@ -89,16 +103,25 @@ wp13_require_command docker
 docker compose -f "${REPO_ROOT}/deploy/docker/docker-compose.yml" \
   config --images | LC_ALL=C sort -u >"${IMAGE_LIST}"
 
+python3 "${REPO_ROOT}/deployment/compose/product_engineering.py" sbom \
+  --repo-root "${REPO_ROOT}" \
+  --output-dir "${BUNDLE_ROOT}/trace"
+python3 "${REPO_ROOT}/deployment/compose/product_engineering.py" manifest \
+  --repo-root "${REPO_ROOT}" \
+  --version "${VERSION}" \
+  --source-date-epoch "${SOURCE_DATE_EPOCH}" \
+  --output "${BUNDLE_ROOT}/release-manifest.json"
+
+CREATED_AT_UTC="$(python3 -c 'import datetime,sys; print(datetime.datetime.fromtimestamp(int(sys.argv[1]), datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))' "${SOURCE_DATE_EPOCH}")"
 {
   printf 'version=%s\n' "${VERSION}"
-  printf 'created_at_utc=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+  printf 'created_at_utc=%s\n' "${CREATED_AT_UTC}"
+  printf 'source_date_epoch=%s\n' "${SOURCE_DATE_EPOCH}"
   printf 'source_commit=%s\n' "$(git -C "${REPO_ROOT}" rev-parse HEAD 2>/dev/null || printf unknown)"
-  if [[ -n "$(git -C "${REPO_ROOT}" status --porcelain --untracked-files=no 2>/dev/null)" ]]; then
-    printf 'source_worktree=dirty\n'
-  else
-    printf 'source_worktree=clean\n'
-  fi
+  printf 'source_tree=%s\n' "$(git -C "${REPO_ROOT}" rev-parse 'HEAD^{tree}' 2>/dev/null || printf unknown)"
   printf 'compose_sha256=%s\n' "$(wp13_sha256_file "${COMPOSE_FILE}")"
+  printf 'release_manifest_sha256=%s\n' "$(wp13_sha256_file "${BUNDLE_ROOT}/release-manifest.json")"
+  printf 'openapi_sha256=%s\n' "$(wp13_sha256_file "${BUNDLE_ROOT}/trace/OPENAPI.yaml")"
   printf 'images_included=%s\n' "$([[ "${SKIP_IMAGES}" -eq 1 ]] && printf no || printf yes)"
   while IFS= read -r image; do
     [[ -n "${image}" ]] || continue
@@ -119,5 +142,8 @@ fi
 
 wp13_write_checksums "${BUNDLE_ROOT}"
 ARCHIVE="${OUTPUT_DIR}/${PACKAGE_NAME}.tar.gz"
-tar -czf "${ARCHIVE}" -C "${TEMP_DIR}" "${PACKAGE_NAME}"
+python3 "${REPO_ROOT}/deployment/compose/reproducible_archive.py" \
+  --source "${BUNDLE_ROOT}" \
+  --output "${ARCHIVE}" \
+  --source-date-epoch "${SOURCE_DATE_EPOCH}"
 wp13_log "offline package created: ${ARCHIVE}"

+ 49 - 2
deployment/compose/preflight.sh

@@ -16,6 +16,8 @@ Options:
   --bundle-root PATH    Bundle or repository root.
   --compose-file PATH   Compose file (default: <root>/deploy/docker/docker-compose.yml).
   --env-file PATH       Optional Compose environment file.
+  --environment-profile PATH
+                        Validated JSON profile; selects matching .env and project.
   --project-name NAME   Compose project (default: dataops-test).
   --checksums-only      Verify checksums and exit before Docker access.
   --skip-checksums      Repository development mode only.
@@ -26,7 +28,8 @@ EOF
 BUNDLE_ROOT="$(wp13_default_root)"
 COMPOSE_FILE=""
 ENV_FILE=""
-PROJECT_NAME="dataops-test"
+ENVIRONMENT_PROFILE=""
+PROJECT_NAME=""
 CHECKSUMS_ONLY=0
 SKIP_CHECKSUMS=0
 
@@ -35,6 +38,7 @@ while [[ $# -gt 0 ]]; do
     --bundle-root) BUNDLE_ROOT="$2"; shift 2 ;;
     --compose-file) COMPOSE_FILE="$2"; shift 2 ;;
     --env-file) ENV_FILE="$2"; shift 2 ;;
+    --environment-profile) ENVIRONMENT_PROFILE="$2"; shift 2 ;;
     --project-name) PROJECT_NAME="$2"; shift 2 ;;
     --checksums-only) CHECKSUMS_ONLY=1; shift ;;
     --skip-checksums) SKIP_CHECKSUMS=1; shift ;;
@@ -53,6 +57,31 @@ if [[ "${CHECKSUMS_ONLY}" -eq 1 ]]; then
   exit 0
 fi
 
+if [[ -n "${ENVIRONMENT_PROFILE}" ]]; then
+  if [[ "${ENVIRONMENT_PROFILE}" != /* ]]; then
+    ENVIRONMENT_PROFILE="${BUNDLE_ROOT}/${ENVIRONMENT_PROFILE}"
+  fi
+  [[ -r "${ENVIRONMENT_PROFILE}" ]] \
+    || wp13_die "environment profile is not readable: ${ENVIRONMENT_PROFILE}"
+  wp13_require_command python3
+  python3 "${SCRIPT_DIR}/product_engineering.py" validate-environments \
+    --profiles-dir "$(dirname "${ENVIRONMENT_PROFILE}")" >/dev/null
+  PROFILE_PROJECT="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["project_name"])' "${ENVIRONMENT_PROFILE}")"
+  PROFILE_ENV_FILE="${ENVIRONMENT_PROFILE%.json}.env"
+  [[ -r "${PROFILE_ENV_FILE}" ]] \
+    || wp13_die "matching environment file is not readable: ${PROFILE_ENV_FILE}"
+  if [[ -n "${PROJECT_NAME}" && "${PROJECT_NAME}" != "${PROFILE_PROJECT}" ]]; then
+    wp13_die "project name does not match environment profile: ${PROJECT_NAME} != ${PROFILE_PROJECT}"
+  fi
+  PROJECT_NAME="${PROFILE_PROJECT}"
+  if [[ -n "${ENV_FILE}" && "$(cd "$(dirname "${ENV_FILE}")" && pwd)/$(basename "${ENV_FILE}")" != "$(cd "$(dirname "${PROFILE_ENV_FILE}")" && pwd)/$(basename "${PROFILE_ENV_FILE}")" ]]; then
+    wp13_die "--env-file must match --environment-profile"
+  fi
+  ENV_FILE="${PROFILE_ENV_FILE}"
+  wp13_log "environment profile validated: $(basename "${ENVIRONMENT_PROFILE}")"
+fi
+PROJECT_NAME="${PROJECT_NAME:-dataops-test}"
+
 wp13_require_command docker
 docker version >/dev/null
 docker compose version >/dev/null
@@ -74,6 +103,24 @@ done < <(
 )
 wp13_log "all default-profile images are available locally"
 
+expected_head="$(wp13_expected_migration_head "${BUNDLE_ROOT}")"
+if [[ -n "${expected_head}" ]]; then
+  wp13_require_command python3
+  backend_image="$(
+    wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" \
+      config --format json \
+      | python3 -c 'import json,sys; print(json.load(sys.stdin)["services"]["backend"]["image"])'
+  )"
+  image_head="$(
+    docker run --rm --network none --read-only --entrypoint alembic "${backend_image}" \
+      -c alembic.ini heads \
+      | awk 'NF {print $1; exit}'
+  )"
+  [[ -n "${image_head}" && "${image_head}" == "${expected_head}" ]] \
+    || wp13_die "image migration head does not match release manifest: image=${image_head:-unknown}, release=${expected_head}"
+  wp13_log "target image migration head matches release: ${image_head}"
+fi
+
 if [[ -n "$(wp13_project_running_ids "${PROJECT_NAME}")" ]]; then
   current="$(
     wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" \
@@ -87,7 +134,7 @@ if [[ -n "$(wp13_project_running_ids "${PROJECT_NAME}")" ]]; then
   )"
   [[ -n "${current}" && "${current}" == "${head}" ]] \
     || wp13_die "database migration is not at head: current=${current:-unknown}, head=${head:-unknown}"
-  wp13_log "database migration is at head: ${current}"
+  wp13_log "running database is self-consistent at migration: ${current}; target=${expected_head:-unversioned}"
 else
   wp13_log "project is not running; migration will be checked after first start"
 fi

+ 764 - 0
deployment/compose/product_engineering.py

@@ -0,0 +1,764 @@
+#!/usr/bin/env python3
+"""Evidence-first product engineering controls for P2-WP11.
+
+The commands in this module validate or render evidence.  They do not promote
+objects, mutate customer source data, switch traffic, or downgrade databases.
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import hashlib
+import json
+import re
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+
+READINESS_CATEGORIES = (
+    "service",
+    "task",
+    "database",
+    "graph",
+    "object_storage",
+    "external_dependency",
+)
+PROMOTABLE_KINDS = {"asset", "standard", "ontology", "rule", "workflow"}
+ENVIRONMENT_ORDER = {"development": 10, "test": 20, "preproduction": 30}
+HEALTHY_STATES = {"healthy", "ready", "succeeded", "completed", "available"}
+SECRET_KEY_PATTERN = re.compile(
+    r"(?:secret|password|token|credential|authorization|api[_-]?key)", re.I
+)
+SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
+COMPOSE_SERVICE_CATEGORIES = {
+    "backend": "service",
+    "frontend": "service",
+    "runner": "task",
+    "n8n": "task",
+    "kestra": "task",
+    "kestra-db-init": "task",
+    "postgres": "database",
+    "neo4j": "graph",
+    "minio": "object_storage",
+    "minio-init": "object_storage",
+    "source-postgres": "external_dependency",
+    "source-mysql": "external_dependency",
+}
+
+
+def _canonical_bytes(value: Any) -> bytes:
+    return json.dumps(
+        value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
+    ).encode("utf-8")
+
+
+def _sha256_bytes(value: bytes) -> str:
+    return hashlib.sha256(value).hexdigest()
+
+
+def _sha256_file(path: Path) -> str:
+    return _sha256_bytes(path.read_bytes())
+
+
+def _tree_manifest(root: Path) -> dict[str, Any]:
+    files: list[dict[str, str]] = []
+    if not root.exists():
+        return {"sha256": None, "file_count": 0}
+    for path in sorted(root.rglob("*")):
+        if not path.is_file():
+            continue
+        relative = path.relative_to(root)
+        if "__pycache__" in relative.parts or path.suffix in {".pyc", ".pyo"}:
+            continue
+        files.append(
+            {"path": relative.as_posix(), "sha256": _sha256_file(path)}
+        )
+    return {
+        "sha256": _sha256_bytes(_canonical_bytes(files)),
+        "file_count": len(files),
+    }
+
+
+def _redact(value: Any) -> Any:
+    if isinstance(value, dict):
+        return {
+            str(key): (
+                "[REDACTED]"
+                if SECRET_KEY_PATTERN.search(str(key))
+                else _redact(item)
+            )
+            for key, item in value.items()
+        }
+    if isinstance(value, list):
+        return [_redact(item) for item in value]
+    return value
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+    value = json.loads(path.read_text(encoding="utf-8"))
+    if not isinstance(value, dict):
+        raise ValueError(f"JSON object required: {path}")
+    return value
+
+
+def _write_json(value: Any, output: str | None = None) -> None:
+    rendered = json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
+    if not output or output == "-":
+        sys.stdout.write(rendered)
+        return
+    path = Path(output)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(rendered, encoding="utf-8")
+
+
+def validate_environment_profiles(profiles_dir: Path) -> dict[str, Any]:
+    profiles: list[dict[str, Any]] = []
+    seen: dict[tuple[str, str], str] = {}
+    collisions: list[dict[str, str]] = []
+    expected = set(ENVIRONMENT_ORDER)
+
+    for path in sorted(profiles_dir.glob("*.json")):
+        profile = _read_json(path)
+        environment = str(profile.get("environment", ""))
+        if environment not in ENVIRONMENT_ORDER:
+            raise ValueError(f"unknown environment in {path.name}: {environment}")
+        if int(profile.get("promotion_order", -1)) != ENVIRONMENT_ORDER[environment]:
+            raise ValueError(f"invalid promotion order for {environment}")
+
+        required_text = ("project_name", "network_name", "data_namespace")
+        for field in required_text:
+            if not str(profile.get(field, "")).strip():
+                raise ValueError(f"{field} is required for {environment}")
+            key = (field, str(profile[field]))
+            if key in seen:
+                collisions.append(
+                    {
+                        "field": field,
+                        "value": str(profile[field]),
+                        "environments": f"{seen[key]},{environment}",
+                    }
+                )
+            seen[key] = environment
+
+        ports = profile.get("ports")
+        if not isinstance(ports, dict) or not ports:
+            raise ValueError(f"ports are required for {environment}")
+        for name, raw_port in sorted(ports.items()):
+            port = int(raw_port)
+            if not 1 <= port <= 65535:
+                raise ValueError(f"invalid port {name}={port} for {environment}")
+            key = ("published_port", str(port))
+            if key in seen:
+                collisions.append(
+                    {
+                        "field": "published_port",
+                        "value": str(port),
+                        "environments": f"{seen[key]},{environment}",
+                    }
+                )
+            seen[key] = environment
+
+        controls = profile.get("controls")
+        if not isinstance(controls, dict) or "auth_required" not in controls:
+            raise ValueError(f"controls.auth_required is required for {environment}")
+        profiles.append(
+            {
+                "environment": environment,
+                "promotion_order": ENVIRONMENT_ORDER[environment],
+                "project_name": profile["project_name"],
+                "network_name": profile["network_name"],
+                "data_namespace": profile["data_namespace"],
+                "ports": dict(sorted((str(k), int(v)) for k, v in ports.items())),
+                "controls": controls,
+                "profile_sha256": _sha256_file(path),
+                "path": path.name,
+            }
+        )
+
+    found = {item["environment"] for item in profiles}
+    if collisions:
+        raise ValueError(f"environment collision detected: {collisions}")
+    if found != expected:
+        raise ValueError(
+            "environment profile set must be development,test,preproduction; "
+            f"found={','.join(sorted(found))}"
+        )
+    profiles.sort(key=lambda item: int(item["promotion_order"]))
+    differences: list[dict[str, Any]] = []
+    baseline = profiles[0]
+    for profile in profiles[1:]:
+        changed = [
+            field
+            for field in ("project_name", "network_name", "data_namespace", "ports", "controls")
+            if profile[field] != baseline[field]
+        ]
+        differences.append(
+            {
+                "from": baseline["environment"],
+                "to": profile["environment"],
+                "changed_fields": changed,
+            }
+        )
+    return {
+        "schema_version": 1,
+        "status": "ready",
+        "promotion_path": [item["environment"] for item in profiles],
+        "profiles": profiles,
+        "collisions": [],
+        "differences": differences,
+    }
+
+
+def build_readiness_report(request: dict[str, Any]) -> dict[str, Any]:
+    components = request.get("components")
+    if not isinstance(components, list):
+        raise ValueError("components must be a list")
+    category_reports: dict[str, dict[str, Any]] = {}
+    blocking: list[str] = []
+    redacted_components: list[dict[str, Any]] = []
+    for component in components:
+        if not isinstance(component, dict):
+            raise ValueError("each component must be an object")
+        category = str(component.get("category", ""))
+        if category not in READINESS_CATEGORIES:
+            raise ValueError(f"unsupported readiness category: {category}")
+        name = str(component.get("name", "")).strip()
+        if not name:
+            raise ValueError("component name is required")
+        status = str(component.get("status", "unknown")).lower()
+        required = bool(component.get("required", True))
+        ready = status in HEALTHY_STATES
+        if required and not ready:
+            blocking.append(name)
+        redacted_components.append(_redact(component))
+
+    for category in READINESS_CATEGORIES:
+        selected = [
+            item for item in redacted_components if item["category"] == category
+        ]
+        required = [item for item in selected if item.get("required", True)]
+        blocked = [
+            item["name"]
+            for item in required
+            if str(item.get("status", "unknown")).lower() not in HEALTHY_STATES
+        ]
+        category_reports[category] = {
+            "status": "ready" if required and not blocked else (
+                "blocked" if blocked else "unconfigured"
+            ),
+            "component_count": len(selected),
+            "required_count": len(required),
+            "blocked_components": blocked,
+        }
+
+    unconfigured = sorted(
+        category
+        for category, report in category_reports.items()
+        if report["status"] == "unconfigured"
+    )
+    return {
+        "schema_version": 1,
+        "environment": str(request.get("environment", "unknown")),
+        "status": "blocked" if blocking or unconfigured else "ready",
+        "blocking_components": sorted(blocking),
+        "unconfigured_categories": unconfigured,
+        "categories": category_reports,
+        "components": redacted_components,
+    }
+
+
+def build_compose_readiness_report(
+    rows: list[dict[str, Any]], *, environment: str
+) -> dict[str, Any]:
+    components: list[dict[str, Any]] = []
+    for row in rows:
+        service = str(row.get("Service", ""))
+        category = COMPOSE_SERVICE_CATEGORIES.get(service)
+        if not category:
+            continue
+        state = str(row.get("State", "unknown")).lower()
+        health = str(row.get("Health", "")).lower()
+        exit_code = int(row.get("ExitCode", -1))
+        completed = state == "exited" and exit_code == 0 and service.endswith("-init")
+        ready = completed or (state == "running" and health in {"", "healthy"})
+        components.append(
+            {
+                "name": service,
+                "category": category,
+                "status": "completed" if completed else ("healthy" if ready else "unhealthy"),
+                "required": True,
+                "details": {
+                    "state": state,
+                    "health": health or "not_reported",
+                    "exit_code": exit_code,
+                    "container_id": str(row.get("ID", ""))[:12],
+                },
+            }
+        )
+    report = build_readiness_report(
+        {"environment": environment, "components": components}
+    )
+    report["collector"] = "docker_compose_ps"
+    return report
+
+
+def collect_compose_readiness(
+    compose_file: Path,
+    *,
+    project_name: str,
+    environment: str,
+    env_file: Path | None = None,
+) -> dict[str, Any]:
+    command = ["docker", "compose"]
+    if env_file:
+        command.extend(["--env-file", str(env_file)])
+    command.extend(
+        ["-p", project_name, "-f", str(compose_file), "ps", "-a", "--format", "json"]
+    )
+    completed = subprocess.run(command, check=True, capture_output=True, text=True)
+    rendered = completed.stdout.strip()
+    rows: list[dict[str, Any]] = []
+    if rendered:
+        try:
+            parsed = json.loads(rendered)
+            rows = parsed if isinstance(parsed, list) else [parsed]
+        except json.JSONDecodeError:
+            rows = [json.loads(line) for line in rendered.splitlines() if line.strip()]
+    return build_compose_readiness_report(rows, environment=environment)
+
+
+def _promotion_digest_payload(bundle: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "schema_version": bundle["schema_version"],
+        "source_environment": bundle["source_environment"],
+        "target_environment": bundle["target_environment"],
+        "approval_ref": bundle["approval_ref"],
+        "objects": bundle["objects"],
+    }
+
+
+def create_promotion_bundle(request: dict[str, Any]) -> dict[str, Any]:
+    source = str(request.get("source_environment", ""))
+    target = str(request.get("target_environment", ""))
+    if source not in ENVIRONMENT_ORDER or target not in ENVIRONMENT_ORDER:
+        raise ValueError("known source and target environments are required")
+    if ENVIRONMENT_ORDER[target] - ENVIRONMENT_ORDER[source] != 10:
+        raise ValueError("promotion must target the adjacent environment")
+    approval_ref = str(request.get("approval_ref", "")).strip()
+    if not approval_ref:
+        raise ValueError("approval_ref is required")
+    objects = request.get("objects")
+    if not isinstance(objects, list) or not objects:
+        raise ValueError("at least one promotion object is required")
+    normalized: list[dict[str, Any]] = []
+    identities: set[tuple[str, str]] = set()
+    for item in objects:
+        if not isinstance(item, dict):
+            raise ValueError("promotion object must be an object")
+        kind = str(item.get("kind", ""))
+        uid = str(item.get("uid", "")).strip()
+        version = str(item.get("version", "")).strip()
+        digest = str(item.get("payload_sha256", "")).lower()
+        rollback_ref = str(item.get("rollback_ref", "")).strip()
+        evidence_refs = item.get("evidence_refs")
+        if kind not in PROMOTABLE_KINDS:
+            raise ValueError(f"unsupported promotion kind: {kind}")
+        if not uid or not version or not rollback_ref:
+            raise ValueError("uid, version and rollback_ref are required")
+        if not SHA256_PATTERN.fullmatch(digest):
+            raise ValueError(f"invalid payload sha256 for {kind}:{uid}")
+        if not isinstance(evidence_refs, list) or not evidence_refs:
+            raise ValueError(f"evidence_refs are required for {kind}:{uid}")
+        identity = (kind, uid)
+        if identity in identities:
+            raise ValueError(f"duplicate promotion object: {kind}:{uid}")
+        identities.add(identity)
+        normalized.append(
+            {
+                "kind": kind,
+                "uid": uid,
+                "version": version,
+                "payload_sha256": digest,
+                "rollback_ref": rollback_ref,
+                "evidence_refs": sorted(str(value) for value in evidence_refs),
+            }
+        )
+    normalized.sort(key=lambda item: (item["kind"], item["uid"]))
+    bundle: dict[str, Any] = {
+        "schema_version": 1,
+        "status": "approved",
+        "source_environment": source,
+        "target_environment": target,
+        "approval_ref": approval_ref,
+        "objects": normalized,
+    }
+    bundle["bundle_sha256"] = _sha256_bytes(
+        _canonical_bytes(_promotion_digest_payload(bundle))
+    )
+    return bundle
+
+
+def verify_promotion_bundle(bundle: dict[str, Any]) -> dict[str, Any]:
+    expected = _sha256_bytes(_canonical_bytes(_promotion_digest_payload(bundle)))
+    actual = str(bundle.get("bundle_sha256", ""))
+    if actual != expected:
+        raise ValueError(f"promotion bundle digest mismatch: expected={expected}, actual={actual}")
+    return {
+        "schema_version": 1,
+        "status": "verified",
+        "bundle_sha256": actual,
+        "object_count": len(bundle.get("objects", [])),
+    }
+
+
+def evaluate_customer_migration(request: dict[str, Any]) -> dict[str, Any]:
+    reasons: list[str] = []
+    mappings = request.get("mappings")
+    if not isinstance(mappings, list):
+        raise ValueError("mappings must be a list")
+    incomplete = [
+        str(item.get("source", "unknown"))
+        for item in mappings
+        if isinstance(item, dict)
+        and item.get("required", True)
+        and (not str(item.get("source", "")).strip() or not str(item.get("target", "")).strip())
+    ]
+    targets = [
+        str(item.get("target", ""))
+        for item in mappings
+        if isinstance(item, dict) and str(item.get("target", "")).strip()
+    ]
+    duplicates = sorted({item for item in targets if targets.count(item) > 1})
+    if incomplete or duplicates:
+        reasons.append("mapping_incomplete")
+
+    incremental = request.get("incremental") or {}
+    latest = int(incremental.get("latest_sequence", 0))
+    caught_up = int(incremental.get("caught_up_sequence", -1))
+    lag = max(0, latest - caught_up)
+    if lag:
+        reasons.append("incremental_not_caught_up")
+
+    reconciliation = request.get("reconciliation") or {}
+    source_count = int(reconciliation.get("source_count", -1))
+    target_count = int(reconciliation.get("target_count", -2))
+    source_digest = str(reconciliation.get("source_digest", ""))
+    target_digest = str(reconciliation.get("target_digest", ""))
+    reconciled = (
+        source_count >= 0
+        and source_count == target_count
+        and bool(source_digest)
+        and source_digest == target_digest
+    )
+    if not reconciled:
+        reasons.append("reconciliation_mismatch")
+
+    rollback = request.get("rollback") or {}
+    rollback_ready = bool(rollback.get("backup_ref") and rollback.get("release_ref"))
+    if not rollback_ready:
+        reasons.append("rollback_not_prepared")
+    snapshot = request.get("source_snapshot") or {}
+    if not snapshot.get("snapshot_ref"):
+        reasons.append("source_snapshot_missing")
+
+    return {
+        "schema_version": 1,
+        "migration_id": str(request.get("migration_id", "unknown")),
+        "mode": "dry_run",
+        "source_mutated": False,
+        "status": "blocked" if reasons else "ready",
+        "blocking_reasons": sorted(set(reasons)),
+        "source_snapshot": _redact(snapshot),
+        "mapping": {
+            "status": "blocked" if incomplete or duplicates else "ready",
+            "mapping_count": len(mappings),
+            "incomplete_sources": incomplete,
+            "duplicate_targets": duplicates,
+        },
+        "incremental_catchup": {
+            "status": "caught_up" if lag == 0 else "lagging",
+            "latest_sequence": latest,
+            "caught_up_sequence": caught_up,
+            "lag": lag,
+        },
+        "reconciliation": {
+            "status": "matched" if reconciled else "mismatch",
+            "source_count": source_count,
+            "target_count": target_count,
+            "source_digest": source_digest,
+            "target_digest": target_digest,
+        },
+        "checkpoints": sorted(str(value) for value in request.get("checkpoints", [])),
+        "rollback": {
+            "status": "prepared" if rollback_ready else "blocked",
+            "backup_ref": rollback.get("backup_ref"),
+            "release_ref": rollback.get("release_ref"),
+            "strategy": "restore_prior_release_and_pre_migration_backup",
+            "database_downgrade": False,
+        },
+    }
+
+
+def _git_value(repo_root: Path, *arguments: str, default: str = "unknown") -> str:
+    try:
+        completed = subprocess.run(
+            ["git", "-C", str(repo_root), *arguments],
+            check=True,
+            capture_output=True,
+            text=True,
+        )
+        return completed.stdout.strip() or default
+    except (OSError, subprocess.CalledProcessError):
+        return default
+
+
+def _migration_head(repo_root: Path) -> str:
+    revisions: dict[str, set[str]] = {}
+    referenced: set[str] = set()
+    for path in sorted((repo_root / "migrations/versions").glob("*.py")):
+        tree = ast.parse(path.read_text(encoding="utf-8"))
+        revision = ""
+        parents: set[str] = set()
+        for node in tree.body:
+            if not isinstance(node, (ast.Assign, ast.AnnAssign)):
+                continue
+            target = node.targets[0] if isinstance(node, ast.Assign) else node.target
+            if not isinstance(target, ast.Name):
+                continue
+            if target.id not in {"revision", "down_revision"}:
+                continue
+            value_node = node.value
+            try:
+                value = ast.literal_eval(value_node)
+            except (ValueError, TypeError):
+                continue
+            if target.id == "revision":
+                revision = str(value)
+            elif value:
+                if isinstance(value, (list, tuple)):
+                    parents.update(str(item) for item in value)
+                else:
+                    parents.add(str(value))
+        if revision:
+            revisions[revision] = parents
+            referenced.update(parents)
+    heads = sorted(set(revisions) - referenced)
+    if len(heads) != 1:
+        raise ValueError(f"exactly one migration head required; found={heads}")
+    return heads[0]
+
+
+def build_release_manifest(
+    repo_root: Path, *, version: str, source_date_epoch: int
+) -> dict[str, Any]:
+    repo_root = repo_root.resolve()
+    openapi = repo_root / "docs/architecture/OPENAPI.yaml"
+    route_match = re.search(
+        r"^x-route-count:\s*(\d+)\s*$",
+        openapi.read_text(encoding="utf-8"),
+        re.MULTILINE,
+    )
+    if not route_match:
+        raise ValueError("OpenAPI x-route-count is missing")
+    source_trees = {
+        name: _tree_manifest(repo_root / name)
+        for name in ("app", "database", "migrations")
+    }
+    release_copies: dict[str, dict[str, Any]] = {}
+    for name, source in source_trees.items():
+        release = _tree_manifest(repo_root / "deployment" / name)
+        release_copies[name] = {
+            **release,
+            "matches_source": release["sha256"] == source["sha256"],
+        }
+    manifest = {
+        "schema_version": 1,
+        "version": version,
+        "source_date_epoch": int(source_date_epoch),
+        "source": {
+            "commit": _git_value(repo_root, "rev-parse", "HEAD"),
+            "tree": _git_value(repo_root, "rev-parse", "HEAD^{tree}"),
+        },
+        "source_trees": source_trees,
+        "release_copies": release_copies,
+        "migration": {
+            "head": _migration_head(repo_root),
+            "tree_sha256": source_trees["migrations"]["sha256"],
+        },
+        "openapi": {
+            "route_count": int(route_match.group(1)),
+            "sha256": _sha256_file(openapi),
+        },
+        "compose": {
+            "sha256": _sha256_file(repo_root / "deploy/docker/docker-compose.yml")
+        },
+        "environment_profiles": _tree_manifest(repo_root / "deployment/environments"),
+        "dependency_inputs": {
+            "backend_sha256": _sha256_file(repo_root / "requirements.txt"),
+            "frontend_sha256": _sha256_file(repo_root / "frontend/package-lock.json"),
+        },
+    }
+    if not all(item["matches_source"] for item in release_copies.values()):
+        raise ValueError("release copies do not match source trees")
+    manifest["manifest_sha256"] = _sha256_bytes(_canonical_bytes(manifest))
+    return manifest
+
+
+def _component(name: str, version: str, ecosystem: str) -> dict[str, str]:
+    return {
+        "type": "library",
+        "name": name,
+        "version": version,
+        "purl": f"pkg:{ecosystem}/{name}@{version}",
+    }
+
+
+def _sbom(name: str, components: list[dict[str, str]], input_digest: str) -> dict[str, Any]:
+    components.sort(key=lambda item: (item["name"].lower(), item["version"]))
+    return {
+        "bomFormat": "CycloneDX",
+        "specVersion": "1.5",
+        "version": 1,
+        "metadata": {
+            "component": {"type": "application", "name": name},
+            "properties": [
+                {"name": "dataops:source-input-sha256", "value": input_digest}
+            ],
+        },
+        "components": components,
+    }
+
+
+def generate_backend_sbom(requirements: Path) -> dict[str, Any]:
+    components: list[dict[str, str]] = []
+    pattern = re.compile(r"^([A-Za-z0-9_.-]+)\s*(===|==|>=|~=|<=|>|<)\s*([^;\s]+)")
+    for raw_line in requirements.read_text(encoding="utf-8").splitlines():
+        line = raw_line.strip()
+        if not line or line.startswith("#"):
+            continue
+        match = pattern.match(line)
+        if not match:
+            raise ValueError(f"unsupported requirement line: {line}")
+        name, operator, version = match.groups()
+        rendered_version = version if operator in {"==", "==="} else operator + version
+        components.append(_component(name, rendered_version, "pypi"))
+    return _sbom("dataops-platform-backend", components, _sha256_file(requirements))
+
+
+def generate_frontend_sbom(package_lock: Path) -> dict[str, Any]:
+    lock = _read_json(package_lock)
+    components: list[dict[str, str]] = []
+    packages = lock.get("packages") or {}
+    if not isinstance(packages, dict):
+        raise ValueError("package-lock packages object is required")
+    for package_path, value in packages.items():
+        if not package_path or not isinstance(value, dict):
+            continue
+        name = str(value.get("name", ""))
+        if not name and "node_modules/" in package_path:
+            name = package_path.rsplit("node_modules/", 1)[-1]
+        version = str(value.get("version", ""))
+        if name and version:
+            components.append(_component(name, version, "npm"))
+    return _sbom("dataops-platform-frontend", components, _sha256_file(package_lock))
+
+
+def generate_sboms(repo_root: Path) -> dict[str, Any]:
+    return {
+        "backend": generate_backend_sbom(repo_root / "requirements.txt"),
+        "frontend": generate_frontend_sbom(repo_root / "frontend/package-lock.json"),
+    }
+
+
+def _parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(description=__doc__)
+    subparsers = parser.add_subparsers(dest="command", required=True)
+
+    validate = subparsers.add_parser("validate-environments")
+    validate.add_argument("--profiles-dir", type=Path, required=True)
+    validate.add_argument("--output")
+
+    readiness = subparsers.add_parser("readiness")
+    readiness.add_argument("--input", type=Path, required=True)
+    readiness.add_argument("--output")
+
+    compose_readiness = subparsers.add_parser("compose-readiness")
+    compose_readiness.add_argument("--compose-file", type=Path, required=True)
+    compose_readiness.add_argument("--project-name", required=True)
+    compose_readiness.add_argument("--environment", required=True)
+    compose_readiness.add_argument("--env-file", type=Path)
+    compose_readiness.add_argument("--output")
+
+    promotion = subparsers.add_parser("promotion")
+    promotion.add_argument("--input", type=Path, required=True)
+    promotion.add_argument("--output")
+    promotion.add_argument("--verify", action="store_true")
+
+    migration = subparsers.add_parser("migration-dry-run")
+    migration.add_argument("--input", type=Path, required=True)
+    migration.add_argument("--output")
+
+    manifest = subparsers.add_parser("manifest")
+    manifest.add_argument("--repo-root", type=Path, required=True)
+    manifest.add_argument("--version", required=True)
+    manifest.add_argument("--source-date-epoch", type=int, required=True)
+    manifest.add_argument("--output")
+
+    sbom = subparsers.add_parser("sbom")
+    sbom.add_argument("--repo-root", type=Path, required=True)
+    sbom.add_argument("--output-dir", required=True)
+    return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+    args = _parser().parse_args(argv)
+    if args.command == "validate-environments":
+        _write_json(validate_environment_profiles(args.profiles_dir), args.output)
+    elif args.command == "readiness":
+        _write_json(build_readiness_report(_read_json(args.input)), args.output)
+    elif args.command == "compose-readiness":
+        _write_json(
+            collect_compose_readiness(
+                args.compose_file,
+                project_name=args.project_name,
+                environment=args.environment,
+                env_file=args.env_file,
+            ),
+            args.output,
+        )
+    elif args.command == "promotion":
+        value = _read_json(args.input)
+        report = verify_promotion_bundle(value) if args.verify else create_promotion_bundle(value)
+        _write_json(report, args.output)
+    elif args.command == "migration-dry-run":
+        _write_json(evaluate_customer_migration(_read_json(args.input)), args.output)
+    elif args.command == "manifest":
+        _write_json(
+            build_release_manifest(
+                args.repo_root,
+                version=args.version,
+                source_date_epoch=args.source_date_epoch,
+            ),
+            args.output,
+        )
+    elif args.command == "sbom":
+        sboms = generate_sboms(args.repo_root)
+        if args.output_dir == "-":
+            _write_json(sboms)
+        else:
+            output_dir = Path(args.output_dir)
+            output_dir.mkdir(parents=True, exist_ok=True)
+            _write_json(sboms["backend"], str(output_dir / "backend.cdx.json"))
+            _write_json(sboms["frontend"], str(output_dir / "frontend.cdx.json"))
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        raise SystemExit(main())
+    except (OSError, ValueError, json.JSONDecodeError) as exc:
+        print(f"ERROR: {exc}", file=sys.stderr)
+        raise SystemExit(2)

+ 55 - 0
deployment/compose/reproducible_archive.py

@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""Create a byte-reproducible tar.gz from one directory tree."""
+
+from __future__ import annotations
+
+import argparse
+import gzip
+import os
+import tarfile
+from pathlib import Path
+
+
+def create_archive(source: Path, output: Path, source_date_epoch: int) -> None:
+    source = source.resolve()
+    output.parent.mkdir(parents=True, exist_ok=True)
+    paths = [source, *sorted(source.rglob("*"), key=lambda path: path.relative_to(source).as_posix())]
+    with output.open("wb") as raw:
+        with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=source_date_epoch) as compressed:
+            with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive:
+                for path in paths:
+                    arcname = source.name if path == source else f"{source.name}/{path.relative_to(source).as_posix()}"
+                    info = archive.gettarinfo(str(path), arcname=arcname)
+                    info.uid = 0
+                    info.gid = 0
+                    info.uname = "root"
+                    info.gname = "root"
+                    info.mtime = source_date_epoch
+                    info.pax_headers = {}
+                    if info.isdir():
+                        info.mode = 0o755
+                        archive.addfile(info)
+                    elif info.isfile():
+                        executable = bool(os.stat(path).st_mode & 0o111)
+                        info.mode = 0o755 if executable else 0o644
+                        with path.open("rb") as handle:
+                            archive.addfile(info, handle)
+                    elif info.issym():
+                        archive.addfile(info)
+                    else:
+                        raise ValueError(f"unsupported archive entry: {path}")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--source", type=Path, required=True)
+    parser.add_argument("--output", type=Path, required=True)
+    parser.add_argument("--source-date-epoch", type=int, required=True)
+    args = parser.parse_args()
+    create_archive(args.source, args.output, args.source_date_epoch)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
+

+ 11 - 0
deployment/compose/wp13-common.sh

@@ -163,3 +163,14 @@ wp13_wait_for_project() {
 wp13_safe_archive_name() {
   printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_'
 }
+
+wp13_expected_migration_head() {
+  local root="$1"
+  local manifest="${root}/release-manifest.json"
+  if [[ ! -f "${manifest}" ]]; then
+    printf ''
+    return
+  fi
+  wp13_require_command python3
+  python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["migration"]["head"])' "${manifest}"
+}

+ 17 - 0
deployment/environments/development.env

@@ -0,0 +1,17 @@
+# P2-WP11 local development isolation profile. No production secrets belong here.
+DATAOPS_ENVIRONMENT=development
+DATAOPS_DATA_NAMESPACE=dataops-development
+DATAOPS_NETWORK_NAME=dataops-development-net
+DATAOPS_POSTGRES_PORT=45432
+SOURCE_POSTGRES_PORT=45433
+SOURCE_MYSQL_PORT=43306
+NEO4J_HTTP_PORT=47474
+NEO4J_BOLT_PORT=47687
+MINIO_API_PORT=49000
+MINIO_CONSOLE_PORT=49001
+N8N_PORT=45678
+KESTRA_PORT=48080
+RUNNER_PORT=45600
+BACKEND_PORT=45500
+FRONTEND_PORT=48183
+DATA_FACTORY_ACTIVATION_ENABLED=false

+ 28 - 0
deployment/environments/development.json

@@ -0,0 +1,28 @@
+{
+  "schema_version": 1,
+  "environment": "development",
+  "promotion_order": 10,
+  "project_name": "dataops-development",
+  "network_name": "dataops-development-net",
+  "data_namespace": "dataops-development",
+  "ports": {
+    "DATAOPS_POSTGRES_PORT": 45432,
+    "SOURCE_POSTGRES_PORT": 45433,
+    "SOURCE_MYSQL_PORT": 43306,
+    "NEO4J_HTTP_PORT": 47474,
+    "NEO4J_BOLT_PORT": 47687,
+    "MINIO_API_PORT": 49000,
+    "MINIO_CONSOLE_PORT": 49001,
+    "N8N_PORT": 45678,
+    "KESTRA_PORT": 48080,
+    "RUNNER_PORT": 45600,
+    "BACKEND_PORT": 45500,
+    "FRONTEND_PORT": 48183
+  },
+  "controls": {
+    "auth_required": true,
+    "tls_required": false,
+    "customer_data_allowed": false,
+    "approval_required_for_promotion": true
+  }
+}

+ 17 - 0
deployment/environments/preproduction.env

@@ -0,0 +1,17 @@
+# P2-WP11 preproduction target profile. Inject secrets through an approved channel.
+DATAOPS_ENVIRONMENT=preproduction
+DATAOPS_DATA_NAMESPACE=dataops-preproduction
+DATAOPS_NETWORK_NAME=dataops-preproduction-net
+DATAOPS_POSTGRES_PORT=61432
+SOURCE_POSTGRES_PORT=61433
+SOURCE_MYSQL_PORT=61306
+NEO4J_HTTP_PORT=61774
+NEO4J_BOLT_PORT=61687
+MINIO_API_PORT=61900
+MINIO_CONSOLE_PORT=61901
+N8N_PORT=61678
+KESTRA_PORT=61808
+RUNNER_PORT=61600
+BACKEND_PORT=61500
+FRONTEND_PORT=61813
+DATA_FACTORY_ACTIVATION_ENABLED=false

+ 28 - 0
deployment/environments/preproduction.json

@@ -0,0 +1,28 @@
+{
+  "schema_version": 1,
+  "environment": "preproduction",
+  "promotion_order": 30,
+  "project_name": "dataops-preproduction",
+  "network_name": "dataops-preproduction-net",
+  "data_namespace": "dataops-preproduction",
+  "ports": {
+    "DATAOPS_POSTGRES_PORT": 61432,
+    "SOURCE_POSTGRES_PORT": 61433,
+    "SOURCE_MYSQL_PORT": 61306,
+    "NEO4J_HTTP_PORT": 61774,
+    "NEO4J_BOLT_PORT": 61687,
+    "MINIO_API_PORT": 61900,
+    "MINIO_CONSOLE_PORT": 61901,
+    "N8N_PORT": 61678,
+    "KESTRA_PORT": 61808,
+    "RUNNER_PORT": 61600,
+    "BACKEND_PORT": 61500,
+    "FRONTEND_PORT": 61813
+  },
+  "controls": {
+    "auth_required": true,
+    "tls_required": true,
+    "customer_data_allowed": false,
+    "approval_required_for_promotion": true
+  }
+}

+ 17 - 0
deployment/environments/test.env

@@ -0,0 +1,17 @@
+# P2-WP11 local test isolation profile. No production secrets belong here.
+DATAOPS_ENVIRONMENT=test
+DATAOPS_DATA_NAMESPACE=dataops-test-isolated
+DATAOPS_NETWORK_NAME=dataops-test-isolated-net
+DATAOPS_POSTGRES_PORT=55432
+SOURCE_POSTGRES_PORT=55433
+SOURCE_MYSQL_PORT=53306
+NEO4J_HTTP_PORT=57474
+NEO4J_BOLT_PORT=57687
+MINIO_API_PORT=59000
+MINIO_CONSOLE_PORT=59001
+N8N_PORT=55678
+KESTRA_PORT=58080
+RUNNER_PORT=55600
+BACKEND_PORT=55500
+FRONTEND_PORT=58183
+DATA_FACTORY_ACTIVATION_ENABLED=false

+ 28 - 0
deployment/environments/test.json

@@ -0,0 +1,28 @@
+{
+  "schema_version": 1,
+  "environment": "test",
+  "promotion_order": 20,
+  "project_name": "dataops-test-isolated",
+  "network_name": "dataops-test-isolated-net",
+  "data_namespace": "dataops-test-isolated",
+  "ports": {
+    "DATAOPS_POSTGRES_PORT": 55432,
+    "SOURCE_POSTGRES_PORT": 55433,
+    "SOURCE_MYSQL_PORT": 53306,
+    "NEO4J_HTTP_PORT": 57474,
+    "NEO4J_BOLT_PORT": 57687,
+    "MINIO_API_PORT": 59000,
+    "MINIO_CONSOLE_PORT": 59001,
+    "N8N_PORT": 55678,
+    "KESTRA_PORT": 58080,
+    "RUNNER_PORT": 55600,
+    "BACKEND_PORT": 55500,
+    "FRONTEND_PORT": 58183
+  },
+  "controls": {
+    "auth_required": true,
+    "tls_required": false,
+    "customer_data_allowed": false,
+    "approval_required_for_promotion": true
+  }
+}

+ 15 - 6
docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md

@@ -484,12 +484,12 @@ UAT 仍待后续完成。详见 `docs/phase2/P2_WP09_AGENT_GOVERNANCE.md`。
 
 **主要工作:**
 
-- [ ] 建立服务、任务、数据库、图、对象存储和外部依赖监控。
-- [ ] 建立开发、测试和预生产基础隔离及配置差异检查。
-- [ ] 建立资产、标准、本体、规则和流程的受控晋级与回滚。
-- [ ] 持续校验 OpenAPI、发布副本、数据库迁移和兼容性。
-- [ ] 建立客户数据迁移 dry-run、映射、增量追平、对账和回滚报告。
-- [ ] 生成可复现制品、版本清单、校验值、升级前检查和恢复证据。
+- [x] 建立服务、任务、数据库、图、对象存储和外部依赖监控。
+- [x] 建立开发、测试和预生产基础隔离及配置差异检查。
+- [x] 建立资产、标准、本体、规则和流程的受控晋级与回滚。
+- [x] 持续校验 OpenAPI、发布副本、数据库迁移和兼容性。
+- [x] 建立客户数据迁移 dry-run、映射、增量追平、对账和回滚报告。
+- [x] 生成可复现制品、版本清单、校验值、升级前检查和恢复证据。
 
 **主要文件区域:**
 
@@ -504,6 +504,15 @@ UAT 仍待后续完成。详见 `docs/phase2/P2_WP09_AGENT_GOVERNANCE.md`。
 **完成门禁:** 第二阶段版本可在隔离环境完成安装、迁移、升级和回滚;源码、
 OpenAPI、迁移、发布副本和制品版本可互相追溯。
 
+**工程状态:** 已完成本地工程门禁。现有 WP13 离线安装、备份、恢复和候选回滚链已
+扩展为六类 Compose 就绪监控、开发/测试/预生产隔离与差异检查、资产/标准/本体/规则/
+流程的相邻环境不可变晋级包、客户迁移 dry-run/增量追平/摘要对账/回滚报告,以及固定
+时间戳和规范归档头的可复现离线包。版本清单可追溯 Git、源码树、发布副本、OpenAPI、
+Alembic head、环境配置和锁定依赖,包内生成 CycloneDX 1.5 SBOM 与全文件校验值。
+晋级和客户迁移均不自动写目标环境或切流;正式生产配置、真实旧系统适配、制品签名、
+企业监控/密钥/制品库及上一正式版本的预生产升级回滚仍需企业集成与 UAT。详见
+`docs/phase2/P2_WP11_PRODUCT_ENGINEERING_DELIVERY.md`。
+
 ### P2-WP12 第二业务域复制
 
 **目标:** 用非设备业务域验证平台化结果,而不是再做一套定制系统。

+ 10 - 10
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -663,7 +663,7 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | DFY-18 | 调度智能 / AI 规划 | 受 Schema 和策略约束的调度规划 | 工程完成,受门禁 |
 | DFY-19 | 引擎迁移 / 双轨运行 | n8n/Kestra 影子运行、对账、切换和回切 | 工程完成,受门禁 |
 | DFY-20 | 引擎迁移 / n8n 退役 | 资产归档、观察、依赖扫描和最终退出门禁 | 规划中 |
-| DFY-21 | 环境发布 / 跨环境晋级 | 生产线、规则和绑定在开发/测试/生产间晋级 | 规划中 |
+| DFY-21 | 环境发布 / 跨环境晋级 | 已建立资产、标准、本体、规则和流程的开发→测试→预生产不可变晋级包、审批/证据/摘要/回滚引用;自动导入与生产发布待企业流水线 | 部分建设 |
 | DFY-22 | 执行运营 / 运行监控 | 运行历史、资源、成本、失败、SLA 和告警 | 部分建设 |
 
 ### 12.9 治理知识库与 AI Agent
@@ -754,22 +754,22 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 | PLT-08 | 容灾 / RTO/RPO | RTO 30 分钟、RPO 15 分钟和容灾演练 | 规划中 |
 | PLT-09 | 备份恢复 / 数据备份 | 已支持 PostgreSQL 逻辑导出和 Compose 全托管卷一致性冷快照;企业配置、密钥加密备份和异地介质仍待验收 | 部分建设 |
 | PLT-10 | 备份恢复 / 恢复验证 | 已完成一次独立项目、独立端口、先验签后恢复的数据对比演练;定期演练和企业灾备报告仍待建设 | 部分建设 |
-| PLT-11 | 可观测性 / 平台监控 | 日志、指标、追踪、健康、告警和依赖拓扑 | 部分建设 |
+| PLT-11 | 可观测性 / 平台监控 | 已覆盖服务、任务、数据库、图、对象存储和外部依赖的 Compose 就绪采集,并复用数据可观测 SLI/SLO、告警和事故链;企业 APM/日志/排班待集成 | 工程完成,受门禁 |
 | PLT-12 | 容量管理 / 规模 | 常态 100–500 用户/50–500 数据源,目标大型集团 | 部分建设 |
-| PLT-13 | 生命周期 / 多环境 | 开发、测试、预生产和生产隔离 | 规划中 |
-| PLT-14 | 生命周期 / 跨环境发布 | 资产、规则、本体、生产线和插件差异、审批和回滚 | 规划中 |
-| PLT-15 | 生命周期 / GitOps | 平台自身配置即代码、自动发布和漂移检测 | 规划中 |
+| PLT-13 | 生命周期 / 多环境 | 已建立开发、测试、预生产的项目/网络/数据命名空间/端口隔离和差异检查;生产隔离待企业环境 | 部分建设 |
+| PLT-14 | 生命周期 / 跨环境发布 | 资产、标准、本体、规则和流程已支持相邻环境不可变晋级、审批证据、摘要校验和回滚引用;自动导入/生产切流待建设 | 部分建设 |
+| PLT-15 | 生命周期 / GitOps | 三环境配置、版本清单和摘要已纳入代码及漂移门禁;自动发布、签名仓库和调谐控制器待建设 | 部分建设 |
 | PLT-16 | 生命周期 / 发布副本 | `app/`、`database/`、`migrations/` 单一源码,`deployment/` 单向同步并有字节级一致性门禁 | 已建设 |
 | PLT-17 | API 治理 / OpenAPI | 从路由生成、契约校验、版本和兼容策略 | 部分建设 |
 | PLT-18 | 扩展平台 / OpenAPI Webhook | 对外 API、事件订阅和 Webhook | 部分建设 |
 | PLT-19 | 扩展平台 / 插件 SDK | 连接器、解析器、质量、通知、审批、Agent 和节点扩展 | 规划中 |
 | PLT-20 | 扩展平台 / 插件仓库 | 租户私有仓库、签名、审核、版本、灰度和回滚 | 规划中 |
 | PLT-21 | 扩展平台 / 插件隔离 | 权限声明、依赖/许可证扫描、沙箱和资源限制 | 规划中 |
-| PLT-22 | 客户迁移 / 迁移评估 | 旧目录、标准、质量、血缘、用户和审批资产盘点 | 规划中 |
-| PLT-23 | 客户迁移 / 迁移执行 | 批量导入、映射、去重、增量追平和断点续传 | 部分建设 |
-| PLT-24 | 客户迁移 / 切换回滚 | 双读对账、灰度切换、回滚和迁移报告 | 规划中 |
-| PLT-25 | 安装升级 / 离线交付 | 已生成含 11 个默认服务镜像、版本清单和全文件 SHA-256 的 Compose 离线包;真实断网干净主机和企业镜像签名仍待验收 | 部分建设 |
-| PLT-26 | 安装升级 / 版本升级 | 已支持镜像/Compose/Alembic 升级前检查及“上一版镜像包 + 升级前备份”的候选项目回滚;生产兼容矩阵和变更审批仍待验收 | 部分建设 |
+| PLT-22 | 客户迁移 / 迁移评估 | 已支持来源快照、字段映射、增量游标、对账及回滚准备的只读 dry-run;真实旧系统资产盘点适配器待建设 | 部分建设 |
+| PLT-23 | 客户迁移 / 迁移执行 | 已支持映射完整性、重复目标、增量追平、检查点和对账门禁;批量写入、断点续传执行器待客户系统适配 | 部分建设 |
+| PLT-24 | 客户迁移 / 切换回滚 | 已生成记录数/摘要对账和上一版制品/升级前备份回滚报告;真实双读、灰度切换和自动回切待建设 | 部分建设 |
+| PLT-25 | 安装升级 / 离线交付 | 已生成含默认服务镜像、可复现归档、版本清单、OpenAPI、CycloneDX SBOM 和全文件 SHA-256 的 Compose 离线包;真实断网干净主机和企业签名仍待验收 | 工程完成,受门禁 |
+| PLT-26 | 安装升级 / 版本升级 | 已支持环境化镜像/Compose/Alembic 升级前检查及“上一版镜像包 + 升级前备份”的候选项目回滚;生产兼容矩阵和变更审批仍待验收 | 工程完成,受门禁 |
 | PLT-27 | 产品配置 / 白标 | Logo、主题、域名、产品名称和登录页 | 规划中 |
 | PLT-28 | 国际化 / 中英文 | 首期中文、二期英文及双语元数据 | 规划中 |
 | PLT-29 | 许可证 / 授权预留 | 部署、功能、容量、在线/离线许可证模型 | 能力预留 |

+ 64 - 0
docs/phase2/P2_WP11_PRODUCT_ENGINEERING_DELIVERY.md

@@ -0,0 +1,64 @@
+# P2-WP11 产品工程与交付说明
+
+## 1. 交付结论
+
+P2-WP11 已完成本地工程门禁。第二阶段能力复用 WP13 的离线安装、备份、恢复和候选
+回滚链,新增六类依赖就绪监控、开发/测试/预生产隔离、治理对象受控晋级、客户迁移
+dry-run、CycloneDX 依赖清单和可复现离线制品。当前结论是“本地工程完成,待企业
+预生产/生产交付 UAT”,不等同于生产发布、客户数据迁移或灾备验收完成。
+
+## 2. 已完成能力
+
+- 平台就绪监控:从 Compose 实例采集服务、任务、PostgreSQL、Neo4j、MinIO 和外部
+  数据源六类状态;必需组件异常或类别未配置时失败关闭,输出前对令牌、密码和凭据脱敏。
+- 多环境基础:开发、测试、预生产分别使用独立 Compose 项目、网络、数据命名空间和
+  12 个发布端口;配置文件具备 SHA-256 和差异报告,环境值碰撞会拒绝通过。
+- 受控晋级:资产、标准、本体、规则和流程共用不可变晋级包,只允许
+  `development -> test -> preproduction` 相邻晋级;每个对象必须绑定版本、载荷摘要、
+  审批号、测试证据和回滚引用,包摘要不一致时拒绝。
+- 客户迁移:以只读 dry-run 检查来源快照、字段映射、重复目标、增量序号、记录数与
+  摘要对账、检查点以及升级前备份/上一版制品;只生成“可迁移/阻断”报告,不执行切换。
+- 交付追溯:版本清单关联 Git 提交/树、`app`/`database`/`migrations`、发布副本、
+  Alembic head、OpenAPI 路由数及摘要、Compose、环境配置和前后端锁定依赖输入。
+- 可复现制品:使用固定 `SOURCE_DATE_EPOCH`、稳定排序、规范 uid/gid/权限和 gzip 头;
+  相同源码、版本、时间戳和镜像输入生成相同归档字节。离线包同时包含 CycloneDX 1.5
+  后端/前端 SBOM、OpenAPI 副本、JSON/文本版本清单和全文件 SHA-256。
+- 升级与恢复:安装前先验签、校验环境、解析 Compose 和检查镜像,启动后核对
+  Alembic current/head,并要求镜像 head 与离线包版本清单完全相同,防止旧镜像“内部
+  自洽”被误判为新版本;回滚继续使用上一版制品加升级前备份恢复候选项目,禁止
+  `alembic downgrade`。
+
+## 3. 工程边界
+
+- 晋级包是可验证的交付证据,不会绕过统一工作中心审批,也不会自动修改目标环境。
+- 客户迁移工具不连接旧系统、不写源数据、不自动双写或切流;真实映射和增量读取适配器
+  需按客户系统建设。
+- 预生产配置声明必须启用 TLS,但本地 Compose 不终止企业 TLS;证书、域名、WAF、
+  密钥注入和正式 SSO 由企业基础设施承接。
+- Compose 就绪报告不是 Prometheus/APM/日志平台的替代品;长期指标、追踪、容量告警和
+  值班通知继续复用 P2-WP05,并在企业监控平台完成集成。
+- 本工作包不建设 Kubernetes/Operator、HA 自动故障转移、跨故障域自动容灾、生产
+  GitOps 自动发布或数据库向后降级。
+
+## 4. 关键交付物
+
+| 交付物 | 位置 |
+|---|---|
+| 产品工程控制面 | `deployment/compose/product_engineering.py` |
+| 可复现归档器 | `deployment/compose/reproducible_archive.py` |
+| 环境配置 | `deployment/environments/` |
+| 离线包生成、安装与预检 | `deployment/compose/package_offline.sh`、`preflight.sh`、`install_offline.sh` |
+| 运维手册 | `docs/runbooks/P2_WP11_PRODUCT_DELIVERY_RUNBOOK.md` |
+| 定向证据 | `docs/validation/P2_WP11_PRODUCT_DELIVERY_EVIDENCE.md` |
+
+## 5. 企业 UAT 与生产前置条件
+
+1. 在企业开发、测试、预生产和生产账户/集群中确认网络、域名、证书、密钥、数据及权限
+   隔离,补齐生产环境配置但不得把密钥提交到 Git。
+2. 选择真实客户旧系统和脱敏样本,确认映射规则、去重主键、增量游标、停机窗口、双读
+   周期、对账口径、切流责任人和失败回退时限。
+3. 在正式构建机生成、签名并扫描最终镜像和离线包,将 SBOM 接入企业漏洞平台和制品库。
+4. 使用上一正式版本及匹配的升级前备份,在隔离预生产环境完整执行升级和候选回滚;由
+   变更负责人审批后才能切换流量。
+5. 将六类就绪状态接入企业指标、日志、追踪、告警和排班系统,并以目标规模验证容量、
+   RPO/RTO 和恢复时间。

+ 107 - 0
docs/runbooks/P2_WP11_PRODUCT_DELIVERY_RUNBOOK.md

@@ -0,0 +1,107 @@
+# P2-WP11 产品交付运行手册
+
+## 1. 固定原则
+
+- `app/`、`database/`、`migrations/` 是源码,`deployment/` 只能单向同步。
+- 所有晋级只允许相邻环境;生产环境不在本地配置中,需企业独立审批后补建。
+- 所有客户迁移先 dry-run,报告未达到 `ready` 不得进入执行窗口。
+- 应用回退使用上一版制品和匹配备份恢复候选项目,禁止数据库 downgrade。
+- 密钥、令牌、客户原始数据和迁移明细不得写入 Git、普通日志或晋级包。
+
+## 2. 环境检查
+
+```bash
+.venv/bin/python deployment/compose/product_engineering.py \
+  validate-environments \
+  --profiles-dir deployment/environments \
+  --output /tmp/dataops-environment-diff.json
+```
+
+开发、测试和预生产 JSON 与同名 `.env` 必须成对存在。预生产的 `tls_required=true`
+是上线门禁声明;本地 Compose 不代替企业 TLS 网关。
+
+安装或升级时直接选择配置,项目名与 `.env` 由配置确定,传入冲突项目名会失败:
+
+```bash
+bash operations/install_offline.sh \
+  --bundle-root "$PWD" \
+  --environment-profile environments/test.json
+```
+
+## 3. 六类就绪监控
+
+```bash
+.venv/bin/python deployment/compose/product_engineering.py \
+  compose-readiness \
+  --compose-file deploy/docker/docker-compose.yml \
+  --env-file deployment/environments/test.env \
+  --project-name dataops-test-isolated \
+  --environment test \
+  --output /tmp/dataops-test-readiness.json
+```
+
+报告必须同时包含 `service`、`task`、`database`、`graph`、`object_storage`、
+`external_dependency`。任何必需组件异常或任一类别缺失时,顶层状态为 `blocked`。
+
+## 4. 治理对象晋级
+
+晋级输入必须包含源/目标环境、统一工作中心审批引用,以及每个资产、标准、本体、规则或
+流程的 `uid`、`version`、`payload_sha256`、`rollback_ref` 和 `evidence_refs`:
+
+```bash
+.venv/bin/python deployment/compose/product_engineering.py promotion \
+  --input /approved/promotion-request.json \
+  --output /approved/promotion-bundle.json
+.venv/bin/python deployment/compose/product_engineering.py promotion \
+  --verify \
+  --input /approved/promotion-bundle.json
+```
+
+生成晋级包不代表目标环境已变更。执行人员还需在目标环境导入、验证对象摘要并把结果回写
+审批任务。摘要不匹配时立即停止,不得重新计算摘要掩盖差异。
+
+## 5. 客户迁移 dry-run
+
+迁移输入至少包含来源快照、字段映射、最新/已追平序号、源目标记录数和摘要、检查点,
+以及升级前备份和上一版制品引用:
+
+```bash
+.venv/bin/python deployment/compose/product_engineering.py migration-dry-run \
+  --input /approved/customer-migration-input.json \
+  --output /approved/customer-migration-report.json
+```
+
+只有映射完整、增量滞后为 0、记录数和摘要一致、回滚引用齐备时报告才为 `ready`。
+本命令始终返回 `mode=dry_run`、`source_mutated=false`,不提供跳过对账开关。
+
+## 6. 可复现离线包
+
+```bash
+bash deployment/sync_release.sh
+bash deployment/compose/package_offline.sh \
+  --version <批准版本> \
+  --source-date-epoch "$(git show -s --format=%ct HEAD)"
+```
+
+联网构建机不得使用 `--skip-images` 生成正式交付包。正式包生成后至少保存:归档
+SHA-256、`release-manifest.json`、`checksums.sha256`、前后端 SBOM、镜像扫描结果、
+签名和审批号。`--skip-images` 只用于合同测试和已预载镜像的隔离演练。
+
+## 7. 升级与候选回滚
+
+升级前执行完整预检并生成一致性备份;升级后核对就绪报告和 Alembic current/head。
+若失败,使用上一版目录和匹配备份恢复新候选项目:
+
+```bash
+bash <previous-release>/operations/rollback.sh \
+  --release-root <previous-release> \
+  --backup-dir <pre-upgrade-backup> \
+  --env-file <isolated-rollback.env> \
+  --project-name <new-candidate-project> \
+  --confirm-project <new-candidate-project> \
+  --start
+```
+
+恢复验证完成后,由企业变更负责人决定是否切流。旧环境下线、卷删除和备份销毁均不由
+本运行手册自动授权。
+

+ 89 - 0
docs/validation/P2_WP11_PRODUCT_DELIVERY_EVIDENCE.md

@@ -0,0 +1,89 @@
+# P2-WP11 产品工程与交付定向验证证据
+
+## 1. 结论
+
+P2-WP11 已达到本地工程完成:六类 Compose 就绪监控、三环境隔离、五类治理对象受控
+晋级、客户迁移 dry-run、可复现制品、SBOM、升级前检查和恢复式回滚均已形成可执行
+契约。验证只覆盖本工作包变更及继承的 WP13 交付链,不执行全量回归。
+
+该结论不代表企业预生产/生产发布成功。真实客户迁移、正式镜像签名、企业制品库、
+生产 TLS/密钥、流量切换、RPO/RTO 和灾备演练仍需企业环境完成。
+
+## 2. 定向合同证据
+
+| 验证面 | 结果 |
+|---|---|
+| 六类就绪监控、敏感信息脱敏和异常阻断 | 通过 |
+| 开发/测试/预生产项目、网络、命名空间和 12 端口零碰撞 | 通过 |
+| 资产/标准/本体/规则/流程相邻晋级、审批证据和篡改拒绝 | 通过 |
+| 客户迁移映射、增量追平、对账、检查点和回滚准备 | 通过 |
+| 版本清单关联源码、发布副本、OpenAPI、迁移和依赖锁 | 通过 |
+| 镜像迁移头与制品清单不一致时失败关闭 | 通过 |
+| CycloneDX 1.5 前后端 SBOM | 通过 |
+| 同输入离线包二次生成 SHA-256 相同 | 通过 |
+| WP13 安装、验签、备份、恢复式回滚契约无回退 | 通过 |
+
+执行命令:
+
+```bash
+PYTHONPATH=. PYTHONPYCACHEPREFIX=/tmp/dataops-wp11-pycache \
+  .venv/bin/pytest -q \
+  tests/acceptance/test_p2_wp11_product_engineering.py \
+  tests/test_p2_wp11_delivery_contract.py \
+  tests/test_wp13_delivery_contract.py
+```
+
+结果:`30 passed`。
+
+叠加 OpenAPI 可复现、发布副本、Compose、MCP 交付和迁移合同后,合并定向结果为
+`58 passed, 4 skipped`;4 项仅因未配置 `TEST_POSTGRES_ADMIN_URL` 而跳过。真实 PostgreSQL
+迁移已由本节的隔离 `360 -> 460` 升级演练覆盖,不据此把跳过项表述为通过。
+
+## 3. 离线包与环境演练
+
+定向演练使用固定版本和 `SOURCE_DATE_EPOCH` 生成不含镜像层的离线包;两次独立目录
+生成的归档字节摘要一致。包内逐文件验签通过,并包含三环境配置、OpenAPI、前后端 SBOM、
+JSON/文本版本清单及产品工程命令。
+
+隔离安装使用 `dataops-test-isolated` 项目和 `deployment/environments/test.env`,不得复用
+现有项目网络、端口或卷。安装后要求全部默认服务健康或一次性初始化成功、数据库
+`current=head=20260802_460`,再采集六类就绪报告。演练清理只允许按该项目标签处理本次
+新建资源。
+
+本地演练记录:
+
+| 项目 | 结果 |
+|---|---|
+| 不含镜像层的合同演练包 | 112 KiB;SHA-256 `394988b466ba9dd5a6186a35b0fedb16496239de69340b20e862ec3dd41d3922` |
+| 版本清单 | manifest SHA-256 `b21084cfad87d544c68383ccd6ccc127cd750baafd512898144f2b13f643fe23`;OpenAPI 395 项;迁移头 `20260802_460` |
+| 旧版本基线 | `dataops-test-backend:latest`,`current=head=20260730_360` |
+| 升级前备份 | 10 个卷 + PostgreSQL 逻辑备份,约 30 MiB;全部 14 个 payload 校验通过;本地维护窗口约 51 秒 |
+| 目标版本预检 | 运行数据库 `360` 自洽;目标镜像与制品清单均为 `460` |
+| 隔离升级 | `360 -> 460` 成功,全部默认服务健康,六类就绪均为 `ready` |
+| 资源隔离 | 演练使用独立项目、网络、12 个端口和项目卷;结束后两个演练项目容器、卷、网络均为 0 |
+
+上述体积和耗时只代表本机小样本,不能作为企业容量、升级窗口、RPO 或 RTO 承诺;正式
+交付包必须包含镜像层并由企业构建、签名和扫描。
+
+## 4. 回滚演练
+
+P2-WP11 没有重新发明回滚机制,复用 WP13 的“旧版镜像 + 匹配升级前备份 -> 新候选
+项目”恢复链。使用上述 `360` 备份恢复 `dataops-wp11-rollback` 候选项目后:
+
+- 候选项目使用旧后端镜像,`current=head=20260730_360`;
+- 候选项目全部默认服务健康,六类就绪均为 `ready`;
+- 已升级的源项目继续使用新后端镜像并保持 `current=head=20260802_460`;
+- 回滚没有执行数据库 downgrade,也没有覆盖、停止或改写升级后的源项目;
+- 验证后只删除两个演练项目标签下的容器、卷和网络,复核残留数均为 0。
+
+定向合同同时确认 `rollback.sh` 调用 `restore.sh` 且不包含 `alembic downgrade`。正式生产
+回滚仍必须使用已签名的上一版完整离线包和匹配备份,由企业变更负责人批准切流;本地旧
+镜像与备份演练不能替代该审批。
+
+## 5. 企业未完成门禁
+
+- 正式构建机、镜像/归档签名、企业制品库、漏洞平台和许可证审查;
+- 四环境账号、网络、数据、密钥和访问权限的真实隔离,生产环境变更审批;
+- 真实旧系统连接器、脱敏客户数据、增量游标、双读周期、对账口径和切流回退;
+- 企业指标、日志、追踪、告警和排班平台联调,目标规模容量与故障注入;
+- 使用上一正式版本及匹配备份完成预生产升级、候选回滚、RPO/RTO 和签字验收。

+ 237 - 0
tests/acceptance/test_p2_wp11_product_engineering.py

@@ -0,0 +1,237 @@
+from __future__ import annotations
+
+import importlib.util
+import json
+from pathlib import Path
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parents[2]
+MODULE_PATH = ROOT / "deployment/compose/product_engineering.py"
+SPEC = importlib.util.spec_from_file_location("product_engineering", MODULE_PATH)
+assert SPEC and SPEC.loader
+product_engineering = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(product_engineering)
+
+
+def test_environment_profiles_are_isolated_and_diff_is_evidence_backed():
+    report = product_engineering.validate_environment_profiles(
+        ROOT / "deployment/environments"
+    )
+
+    assert report["status"] == "ready"
+    assert report["promotion_path"] == [
+        "development",
+        "test",
+        "preproduction",
+    ]
+    assert report["collisions"] == []
+    assert {
+        item["environment"] for item in report["profiles"]
+    } == {"development", "test", "preproduction"}
+    assert all(item["profile_sha256"] for item in report["profiles"])
+    assert report["differences"]
+
+
+def test_environment_profile_collision_is_rejected(tmp_path: Path):
+    profile = {
+        "schema_version": 1,
+        "environment": "development",
+        "promotion_order": 10,
+        "project_name": "dataops-development",
+        "network_name": "dataops-development-net",
+        "data_namespace": "dataops-development",
+        "ports": {"BACKEND_PORT": 45500},
+        "controls": {"auth_required": True, "tls_required": False},
+    }
+    (tmp_path / "development.json").write_text(
+        json.dumps(profile), encoding="utf-8"
+    )
+    profile.update(environment="test", promotion_order=20)
+    (tmp_path / "test.json").write_text(json.dumps(profile), encoding="utf-8")
+
+    with pytest.raises(ValueError, match="collision"):
+        product_engineering.validate_environment_profiles(tmp_path)
+
+
+def test_readiness_covers_six_dependency_classes_and_redacts_secrets():
+    components = []
+    for category in product_engineering.READINESS_CATEGORIES:
+        components.append(
+            {
+                "name": f"sample-{category}",
+                "category": category,
+                "status": "healthy",
+                "required": True,
+                "details": {"endpoint": "local", "api_token": "do-not-return"},
+            }
+        )
+
+    report = product_engineering.build_readiness_report(
+        {"environment": "test", "components": components}
+    )
+
+    assert report["status"] == "ready"
+    assert set(report["categories"]) == set(product_engineering.READINESS_CATEGORIES)
+    assert all(item["status"] == "ready" for item in report["categories"].values())
+    assert "do-not-return" not in json.dumps(report)
+    assert "[REDACTED]" in json.dumps(report)
+
+
+def test_required_unhealthy_dependency_blocks_readiness():
+    report = product_engineering.build_readiness_report(
+        {
+            "environment": "test",
+            "components": [
+                {
+                    "name": "postgres",
+                    "category": "database",
+                    "status": "unhealthy",
+                    "required": True,
+                }
+            ],
+        }
+    )
+    assert report["status"] == "blocked"
+    assert report["blocking_components"] == ["postgres"]
+
+
+def test_compose_state_is_mapped_to_platform_dependency_readiness():
+    rows = [
+        {"Service": "backend", "State": "running", "Health": "healthy", "ExitCode": 0},
+        {"Service": "runner", "State": "running", "Health": "healthy", "ExitCode": 0},
+        {"Service": "postgres", "State": "running", "Health": "healthy", "ExitCode": 0},
+        {"Service": "neo4j", "State": "running", "Health": "healthy", "ExitCode": 0},
+        {"Service": "minio", "State": "running", "Health": "healthy", "ExitCode": 0},
+        {"Service": "source-postgres", "State": "running", "Health": "healthy", "ExitCode": 0},
+    ]
+
+    report = product_engineering.build_compose_readiness_report(
+        rows, environment="test"
+    )
+
+    assert report["status"] == "ready"
+    assert set(report["categories"]) == set(product_engineering.READINESS_CATEGORIES)
+    assert report["collector"] == "docker_compose_ps"
+
+
+def test_governed_promotion_is_adjacent_immutable_and_tamper_evident():
+    request = {
+        "source_environment": "development",
+        "target_environment": "test",
+        "approval_ref": "work-item:P2-WP11-approval-01",
+        "objects": [
+            {
+                "kind": kind,
+                "uid": f"{kind}-01",
+                "version": "1.0.0",
+                "payload_sha256": "a" * 64,
+                "rollback_ref": f"{kind}:0.9.0",
+                "evidence_refs": [f"test:{kind}-contract"],
+            }
+            for kind in sorted(product_engineering.PROMOTABLE_KINDS)
+        ],
+    }
+
+    bundle = product_engineering.create_promotion_bundle(request)
+    assert bundle["status"] == "approved"
+    assert product_engineering.verify_promotion_bundle(bundle)["status"] == "verified"
+
+    tampered = json.loads(json.dumps(bundle))
+    tampered["objects"][0]["version"] = "2.0.0"
+    with pytest.raises(ValueError, match="digest"):
+        product_engineering.verify_promotion_bundle(tampered)
+
+    request["target_environment"] = "preproduction"
+    with pytest.raises(ValueError, match="adjacent"):
+        product_engineering.create_promotion_bundle(request)
+
+
+def test_customer_migration_dry_run_reports_mapping_catchup_reconciliation_and_rollback():
+    request = {
+        "migration_id": "customer-a-catalog-001",
+        "source_snapshot": {"snapshot_ref": "legacy:2026-08-02T00:00Z", "count": 3},
+        "mappings": [
+            {"source": "asset_code", "target": "asset_uid", "required": True},
+            {"source": "asset_name", "target": "name", "required": True},
+        ],
+        "incremental": {"latest_sequence": 12, "caught_up_sequence": 12},
+        "reconciliation": {
+            "source_count": 3,
+            "target_count": 3,
+            "source_digest": "b" * 64,
+            "target_digest": "b" * 64,
+        },
+        "checkpoints": ["snapshot", "baseline", "catchup", "reconcile"],
+        "rollback": {
+            "backup_ref": "backup:pre-migration-001",
+            "release_ref": "release:20260802",
+        },
+    }
+
+    report = product_engineering.evaluate_customer_migration(request)
+
+    assert report["mode"] == "dry_run"
+    assert report["source_mutated"] is False
+    assert report["status"] == "ready"
+    assert report["mapping"]["status"] == "ready"
+    assert report["incremental_catchup"]["lag"] == 0
+    assert report["reconciliation"]["status"] == "matched"
+    assert report["rollback"]["status"] == "prepared"
+
+
+def test_customer_migration_blocks_incomplete_mapping_and_mismatch():
+    report = product_engineering.evaluate_customer_migration(
+        {
+            "migration_id": "blocked",
+            "source_snapshot": {"snapshot_ref": "legacy:s1", "count": 2},
+            "mappings": [{"source": "id", "target": "", "required": True}],
+            "incremental": {"latest_sequence": 5, "caught_up_sequence": 3},
+            "reconciliation": {
+                "source_count": 2,
+                "target_count": 1,
+                "source_digest": "a" * 64,
+                "target_digest": "b" * 64,
+            },
+            "checkpoints": [],
+            "rollback": {},
+        }
+    )
+    assert report["status"] == "blocked"
+    assert set(report["blocking_reasons"]) >= {
+        "mapping_incomplete",
+        "incremental_not_caught_up",
+        "reconciliation_mismatch",
+        "rollback_not_prepared",
+    }
+
+
+def test_release_manifest_is_deterministic_and_traces_sources():
+    first = product_engineering.build_release_manifest(
+        ROOT, version="contract", source_date_epoch=1_775_251_200
+    )
+    second = product_engineering.build_release_manifest(
+        ROOT, version="contract", source_date_epoch=1_775_251_200
+    )
+
+    assert first == second
+    assert first["migration"]["head"] == "20260802_460"
+    assert first["openapi"]["route_count"] > 100
+    assert first["openapi"]["sha256"]
+    assert first["source_trees"]["app"]["sha256"]
+    assert first["release_copies"]["app"]["matches_source"] is True
+    assert first["release_copies"]["migrations"]["matches_source"] is True
+
+
+def test_cyclonedx_sboms_are_generated_from_locked_inputs():
+    backend = product_engineering.generate_backend_sbom(ROOT / "requirements.txt")
+    frontend = product_engineering.generate_frontend_sbom(
+        ROOT / "frontend/package-lock.json"
+    )
+
+    assert backend["bomFormat"] == "CycloneDX"
+    assert backend["specVersion"] == "1.5"
+    assert any(item["name"] == "Flask" for item in backend["components"])
+    assert frontend["bomFormat"] == "CycloneDX"
+    assert any(item["name"] == "vue" for item in frontend["components"])

+ 180 - 0
tests/test_p2_wp11_delivery_contract.py

@@ -0,0 +1,180 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import subprocess
+import tarfile
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+OPERATIONS = ROOT / "deployment/compose"
+
+
+def _package(output: Path) -> Path:
+    completed = subprocess.run(
+        [
+            "bash",
+            str(OPERATIONS / "package_offline.sh"),
+            "--repo-root",
+            str(ROOT),
+            "--output-dir",
+            str(output),
+            "--version",
+            "p2-wp11-contract",
+            "--source-date-epoch",
+            "1775251200",
+            "--skip-images",
+        ],
+        cwd=ROOT,
+        capture_output=True,
+        text=True,
+    )
+    assert completed.returncode == 0, completed.stderr
+    return output / "dataops-platform-offline-p2-wp11-contract.tar.gz"
+
+
+def test_package_is_reproducible_and_contains_trace_evidence(tmp_path: Path):
+    first = _package(tmp_path / "first")
+    second = _package(tmp_path / "second")
+
+    assert hashlib.sha256(first.read_bytes()).digest() == hashlib.sha256(
+        second.read_bytes()
+    ).digest()
+
+    with tarfile.open(first, "r:gz") as archive:
+        names = set(archive.getnames())
+
+    prefix = "dataops-platform-offline-p2-wp11-contract/"
+    expected = {
+        "environments/development.json",
+        "environments/test.json",
+        "environments/preproduction.json",
+        "operations/product_engineering.py",
+        "operations/reproducible_archive.py",
+        "trace/OPENAPI.yaml",
+        "trace/backend.cdx.json",
+        "trace/frontend.cdx.json",
+        "release-manifest.json",
+        "release-manifest.txt",
+        "checksums.sha256",
+    }
+    assert {prefix + item for item in expected} <= names
+
+
+def test_environment_files_match_their_profiles():
+    for profile_path in sorted((ROOT / "deployment/environments").glob("*.json")):
+        profile = json.loads(profile_path.read_text(encoding="utf-8"))
+        env_path = profile_path.with_suffix(".env")
+        values = dict(
+            line.split("=", 1)
+            for line in env_path.read_text(encoding="utf-8").splitlines()
+            if line and not line.startswith("#")
+        )
+        assert values["DATAOPS_NETWORK_NAME"] == profile["network_name"]
+        assert values["DATAOPS_ENVIRONMENT"] == profile["environment"]
+        assert values["DATAOPS_DATA_NAMESPACE"] == profile["data_namespace"]
+        for name, value in profile["ports"].items():
+            assert values[name] == str(value)
+
+
+def test_preflight_and_install_accept_environment_profile():
+    for script in ("preflight.sh", "install_offline.sh"):
+        help_text = subprocess.run(
+            ["bash", str(OPERATIONS / script), "--help"],
+            cwd=ROOT,
+            capture_output=True,
+            text=True,
+            check=True,
+        ).stdout
+        assert "--environment-profile" in help_text
+
+
+def test_install_rejects_image_head_that_differs_from_release_manifest():
+    install = (OPERATIONS / "install_offline.sh").read_text(encoding="utf-8")
+    preflight = (OPERATIONS / "preflight.sh").read_text(encoding="utf-8")
+    common = (OPERATIONS / "wp13-common.sh").read_text(encoding="utf-8")
+
+    assert "wp13_expected_migration_head" in common
+    assert "installed image migration head does not match release manifest" in install
+    assert "image migration head does not match release manifest" in preflight
+
+
+def test_preflight_fails_closed_when_running_image_head_is_stale(tmp_path: Path):
+    bundle = tmp_path / "bundle"
+    fake_bin = tmp_path / "bin"
+    bundle.mkdir()
+    fake_bin.mkdir()
+    compose = bundle / "docker-compose.yml"
+    compose.write_text("services: {}\n", encoding="utf-8")
+    (bundle / "release-manifest.json").write_text(
+        json.dumps({"migration": {"head": "20260802_460"}}),
+        encoding="utf-8",
+    )
+    fake_docker = fake_bin / "docker"
+    fake_docker.write_text(
+        """#!/usr/bin/env bash
+set -euo pipefail
+arguments="$*"
+if [[ "${arguments}" == "ps -q --filter label=com.docker.compose.project=stale-project" ]]; then
+  printf 'container-id\\n'
+elif [[ "${arguments}" == *" config --format json" ]]; then
+  printf '{"services":{"backend":{"image":"fake-backend"}}}\\n'
+elif [[ "${arguments}" == "run --rm --network none --read-only --entrypoint alembic fake-backend -c alembic.ini heads" ]]; then
+  printf '20260730_360 (head)\\n'
+elif [[ "${arguments}" == *" alembic -c alembic.ini current" ]]; then
+  printf '20260730_360 (head)\\n'
+elif [[ "${arguments}" == *" alembic -c alembic.ini heads" ]]; then
+  printf '20260730_360 (head)\\n'
+fi
+""",
+        encoding="utf-8",
+    )
+    fake_docker.chmod(0o755)
+    env = os.environ.copy()
+    env["PATH"] = f"{fake_bin}:/usr/bin:/bin"
+
+    completed = subprocess.run(
+        [
+            "bash",
+            str(OPERATIONS / "preflight.sh"),
+            "--bundle-root",
+            str(bundle),
+            "--compose-file",
+            str(compose),
+            "--project-name",
+            "stale-project",
+            "--skip-checksums",
+        ],
+        cwd=ROOT,
+        env=env,
+        capture_output=True,
+        text=True,
+    )
+
+    assert completed.returncode != 0
+    assert "image migration head does not match release manifest" in (
+        completed.stdout + completed.stderr
+    )
+
+
+def test_product_engineering_cli_is_read_only_by_default():
+    for arguments in (
+        ["validate-environments", "--profiles-dir", "deployment/environments"],
+        ["manifest", "--repo-root", ".", "--version", "contract", "--source-date-epoch", "1775251200"],
+        ["sbom", "--repo-root", ".", "--output-dir", "-"],
+    ):
+        completed = subprocess.run(
+            [str(ROOT / ".venv/bin/python"), str(OPERATIONS / "product_engineering.py"), *arguments],
+            cwd=ROOT,
+            capture_output=True,
+            text=True,
+        )
+        assert completed.returncode == 0, completed.stderr
+
+
+def test_rollback_remains_restore_based_and_never_downgrades_database():
+    source = (OPERATIONS / "rollback.sh").read_text(encoding="utf-8")
+    assert "restore.sh" in source
+    assert "alembic downgrade" not in source