Ver código fonte

feat: establish phase three enterprise acceptance gate

马小龙 2 semanas atrás
pai
commit
655fea6cd0

+ 7 - 1
docs/DATAOPS_PHASE3_6_MONTH_DEVELOPMENT_PLAN_20260802.md

@@ -195,7 +195,7 @@ P3-WP01 的企业验收和 P3-WP07 的交付工程是贯穿性工作。第 1~2
 | 编号 | 工作包 | 优先级 | 关联台账模块 | 建议周期 | 初始状态 | 首要启动门禁 |
 |---|---|---:|---|---|---|---|
 | P3-WP00 | 范围、架构与企业依赖基线 | P0 | 全部第三阶段范围 | 第 1~2 周 | 工程基线完成待企业输入确认 | 企业输入和实名责任人待确认;未实现后续功能 |
-| P3-WP01 | 第二业务域企业正式验收 | P0 | P2-WP13 外部门禁 | 第 1~4 周 | 外部条件待满足 | 真实用户、来源、配置、预生产环境 |
+| P3-WP01 | 第二业务域企业正式验收 | P0 | P2-WP13 外部门禁 | 第 1~4 周 | 工程完成待企业 UAT | 真实用户、来源、配置、预生产环境 |
 | P3-WP02 | 企业统一身份与 SSO | P0 | IAM-05~12、IAM-17 | 第 5~8 周 | 外部条件待满足 | IdP 测试租户和映射规则 |
 | P3-WP03 | 企业连接器与扩展契约 | P0 | CON-06~15、CON-21~22、IAM-13 | 第 5~8 周 | 外部条件待满足 | 两个目标来源和只读账号 |
 | P3-WP04 | 边缘网关与数据不出域 | P0 | CON-17~20、SEC-05~07 | 第 9~12 周 | 外部条件待满足 | 网络区、出站和脱敏策略 |
@@ -269,6 +269,8 @@ AI、多租户、BI/AI、成本或插件输入。P0 企业、安全、恢复和
 
 **主要工作:**
 
+- [x] 建立机器可读验收包与失败关闭验收器,区分 `BLOCKED`、`FAILED` 和 `ACCEPTED`。
+- [x] 建立企业现场执行、预生产演练、培训、缺陷和五方签署空白记录,不预填企业事实或签名。
 - [ ] 使用企业真实账号、责任矩阵和脱敏只读来源执行 UAT-021~024。
 - [ ] 核对核心对象入账率、增量识别、责任覆盖、质量闭环、事故和产品证据指标。
 - [ ] 在预生产执行上一正式版本到 `v0.3.0-phase2` 的安装、升级、备份和回滚演练。
@@ -285,6 +287,10 @@ AI、多租户、BI/AI、成本或插件输入。P0 企业、安全、恢复和
 **完成门禁:** 企业 UAT 指标达到批准值,P0 为 0,P1 有处置决定,培训、预生产演练
 和五方签署完成。本工作包以验收与必要缺陷修复为主,不无条件重做第二阶段功能。
 
+**当前状态:** `ENGINEERING_READY_BLOCKED_EXTERNAL`。工程验收材料与自动判定契约已就绪;
+真实企业账号、来源、脱敏样本、预生产演练、培训、缺陷复核和五方实名签署仍未取得,
+以上六项企业完成项保持未勾选,不得以本地或合成证据解除门禁。
+
 ### P3-WP02 企业统一身份与 SSO
 
 **目标:** 接入企业身份体系并完成登录、映射、生命周期、会话、应急和审计闭环。

+ 15 - 0
docs/acceptance/P3_WP01_DEFECT_REGISTER.md

@@ -0,0 +1,15 @@
+# P3-WP01 企业正式验收缺陷台账
+
+状态:`ENGINEERING_READY_BLOCKED_EXTERNAL`。企业 UAT 尚未执行,本台账为空不等于 P0/P1 为零。
+
+| 缺陷 ID(写入 `open_p1_ids`) | UAT 用例 | 严重级别 | 描述 | 负责人 | 状态 | 关闭或书面处置证据 |
+|---|---|---|---|---|---|---|
+| 待企业执行后登记 | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+
+最终门禁必须由不同的业务复核人和技术复核人确认 `p0_open = 0`,并记录含时区的复核时间和证据摘要。
+
+## P1 书面处置
+
+- `p1_open = 0`:`open_p1_ids`、`covered_p1_ids` 均为空,`p1_disposition.status = NOT_REQUIRED`,`covered_p1_count = 0`。
+- `p1_open > 0`:`open_p1_ids` 数量必须等于 `p1_open`,ID 非占位且唯一;`status = WRITTEN_DECISION_APPROVED`,`covered_p1_count` 必须等于 `p1_open`,`covered_p1_ids` 集合必须与 `open_p1_ids` 精确相等。
+- 不同的业务和技术实名批准人共同决定,记录含时区的决定时间和 `ENTERPRISE_FORMAL` 证据摘要。缺失、少覆盖、重复/占位 ID、自批或代批均不能通过。

+ 140 - 0
docs/acceptance/P3_WP01_ENTERPRISE_ACCEPTANCE.json

@@ -0,0 +1,140 @@
+{
+  "schema_version": "1.0",
+  "work_package": "P3-WP01",
+  "declared_status": "ENGINEERING_READY_BLOCKED_EXTERNAL",
+  "source_cases": [
+    "P2-WP13-UAT-021",
+    "P2-WP13-UAT-022",
+    "P2-WP13-UAT-023",
+    "P2-WP13-UAT-024"
+  ],
+  "execution_context": {
+    "enterprise_name": null,
+    "environment_id": null,
+    "environment_type": "TBD_EXTERNAL",
+    "executed_commit": null,
+    "started_at": null,
+    "completed_at": null
+  },
+  "source": {
+    "source_id": null,
+    "source_type": "TBD_EXTERNAL",
+    "readonly": null,
+    "credentials_in_package": false,
+    "network_zone": "TBD_EXTERNAL"
+  },
+  "sample_summary": {
+    "classification": "TBD_EXTERNAL",
+    "object_count": null,
+    "row_count": null,
+    "sha256": "TBD_EXTERNAL",
+    "raw_data_in_evidence": false
+  },
+  "release_artifact": {
+    "version": "v0.3.0-phase2",
+    "artifact_name": "TBD_EXTERNAL",
+    "sha256": "TBD_EXTERNAL",
+    "evidence_refs": [
+      {
+        "path": "validation/P3_WP01_PREPRODUCTION_REHEARSAL_RECORD.md",
+        "sha256": "TBD_EXTERNAL",
+        "evidence_level": "TBD_EXTERNAL"
+      }
+    ]
+  },
+  "uat_cases": [
+    {
+      "id": "P2-WP13-UAT-021",
+      "result": "BLOCKED_EXTERNAL",
+      "environment_id": null,
+      "executed_at": null,
+      "executed_by_person_id": null,
+      "approved_by_person_id": null,
+      "evidence_refs": [{"path": "acceptance/P3_WP01_EXECUTION_GUIDE.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]
+    },
+    {
+      "id": "P2-WP13-UAT-022",
+      "result": "BLOCKED_EXTERNAL",
+      "environment_id": null,
+      "executed_at": null,
+      "executed_by_person_id": null,
+      "approved_by_person_id": null,
+      "evidence_refs": [{"path": "acceptance/P3_WP01_FIVE_PARTY_SIGNOFF.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]
+    },
+    {
+      "id": "P2-WP13-UAT-023",
+      "result": "BLOCKED_EXTERNAL",
+      "environment_id": null,
+      "executed_at": null,
+      "executed_by_person_id": null,
+      "approved_by_person_id": null,
+      "evidence_refs": [{"path": "validation/P3_WP01_PREPRODUCTION_REHEARSAL_RECORD.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]
+    },
+    {
+      "id": "P2-WP13-UAT-024",
+      "result": "BLOCKED_EXTERNAL",
+      "environment_id": null,
+      "executed_at": null,
+      "executed_by_person_id": null,
+      "approved_by_person_id": null,
+      "evidence_refs": [{"path": "training/P3_WP01_TRAINING_EXECUTION_RECORD.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]
+    }
+  ],
+  "metrics": [
+    {"id": "core_object_onboarding_rate", "comparator": "GTE", "target": 95, "unit": "percent", "status": "BLOCKED_EXTERNAL", "numerator": null, "denominator": null, "value": null, "window_start": null, "window_end": null, "approved_by_person_id": null, "evidence_refs": [{"path": "validation/P3_WP01_ACCEPTANCE_EVIDENCE.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]},
+    {"id": "incremental_change_accuracy", "comparator": "GTE", "target": 99, "unit": "percent", "status": "BLOCKED_EXTERNAL", "numerator": null, "denominator": null, "value": null, "window_start": null, "window_end": null, "approved_by_person_id": null, "evidence_refs": [{"path": "validation/P3_WP01_ACCEPTANCE_EVIDENCE.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]},
+    {"id": "responsibility_coverage", "comparator": "GTE", "target": 95, "unit": "percent", "status": "BLOCKED_EXTERNAL", "numerator": null, "denominator": null, "value": null, "window_start": null, "window_end": null, "approved_by_person_id": null, "evidence_refs": [{"path": "validation/P3_WP01_ACCEPTANCE_EVIDENCE.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]},
+    {"id": "quality_issue_closure_rate", "comparator": "GTE", "target": 85, "unit": "percent", "status": "BLOCKED_EXTERNAL", "numerator": null, "denominator": null, "value": null, "window_start": null, "window_end": null, "approved_by_person_id": null, "evidence_refs": [{"path": "validation/P3_WP01_ACCEPTANCE_EVIDENCE.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]},
+    {"id": "incident_evidence_coverage", "comparator": "GTE", "target": 100, "unit": "percent", "status": "BLOCKED_EXTERNAL", "numerator": null, "denominator": null, "value": null, "window_start": null, "window_end": null, "approved_by_person_id": null, "evidence_refs": [{"path": "validation/P3_WP01_ACCEPTANCE_EVIDENCE.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]},
+    {"id": "data_product_evidence_coverage", "comparator": "GTE", "target": 100, "unit": "percent", "status": "BLOCKED_EXTERNAL", "numerator": null, "denominator": null, "value": null, "window_start": null, "window_end": null, "approved_by_person_id": null, "evidence_refs": [{"path": "validation/P3_WP01_ACCEPTANCE_EVIDENCE.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]}
+  ],
+  "preproduction_rehearsal": {
+    "status": "BLOCKED_EXTERNAL",
+    "environment_id": null,
+    "executed_at": null,
+    "executed_by_person_id": null,
+    "approved_by_person_id": null,
+    "rto_target_minutes": 30,
+    "rpo_target_minutes": 15,
+    "rto_minutes": null,
+    "rpo_minutes": null,
+    "steps": [
+      {"id": "install", "status": "BLOCKED_EXTERNAL"},
+      {"id": "upgrade", "status": "BLOCKED_EXTERNAL"},
+      {"id": "backup", "status": "BLOCKED_EXTERNAL"},
+      {"id": "candidate_rollback", "status": "BLOCKED_EXTERNAL"},
+      {"id": "rto_rpo", "status": "BLOCKED_EXTERNAL"}
+    ],
+    "evidence_refs": [{"path": "validation/P3_WP01_PREPRODUCTION_REHEARSAL_RECORD.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]
+  },
+  "training_records": [
+    {"id": "user_training", "status": "BLOCKED_EXTERNAL", "participants": [], "trainer_person_id": null, "completed_at": null, "exercise_passed": null, "retraining_required": null, "retraining_completed": null, "evidence_refs": [{"path": "training/P3_WP01_TRAINING_EXECUTION_RECORD.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]},
+    {"id": "operations_training", "status": "BLOCKED_EXTERNAL", "participants": [], "trainer_person_id": null, "completed_at": null, "exercise_passed": null, "retraining_required": null, "retraining_completed": null, "evidence_refs": [{"path": "training/P3_WP01_TRAINING_EXECUTION_RECORD.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]}
+  ],
+  "defect_gate": {
+    "status": "BLOCKED_EXTERNAL",
+    "p0_open": null,
+    "p1_open": null,
+    "open_p1_ids": [],
+    "p1_disposition": {
+      "status": "TBD_EXTERNAL",
+      "covered_p1_count": null,
+      "covered_p1_ids": [],
+      "business_person_id": null,
+      "technical_person_id": null,
+      "decided_at": null,
+      "evidence_refs": [{"path": "acceptance/P3_WP01_DEFECT_REGISTER.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]
+    },
+    "reviewed_by_business_person_id": null,
+    "reviewed_by_technical_person_id": null,
+    "reviewed_at": null,
+    "evidence_refs": [{"path": "acceptance/P3_WP01_DEFECT_REGISTER.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]
+  },
+  "signoffs": [
+    {"role": "product_owner", "status": "BLOCKED_EXTERNAL", "person_id": null, "person_name": null, "signed_at": null, "evidence_refs": [{"path": "acceptance/P3_WP01_FIVE_PARTY_SIGNOFF.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]},
+    {"role": "business_owner", "status": "BLOCKED_EXTERNAL", "person_id": null, "person_name": null, "signed_at": null, "evidence_refs": [{"path": "acceptance/P3_WP01_FIVE_PARTY_SIGNOFF.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]},
+    {"role": "technical_owner", "status": "BLOCKED_EXTERNAL", "person_id": null, "person_name": null, "signed_at": null, "evidence_refs": [{"path": "acceptance/P3_WP01_FIVE_PARTY_SIGNOFF.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]},
+    {"role": "security_owner", "status": "BLOCKED_EXTERNAL", "person_id": null, "person_name": null, "signed_at": null, "evidence_refs": [{"path": "acceptance/P3_WP01_FIVE_PARTY_SIGNOFF.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]},
+    {"role": "operations_owner", "status": "BLOCKED_EXTERNAL", "person_id": null, "person_name": null, "signed_at": null, "evidence_refs": [{"path": "acceptance/P3_WP01_FIVE_PARTY_SIGNOFF.md", "sha256": "TBD_EXTERNAL", "evidence_level": "TBD_EXTERNAL"}]}
+  ]
+}

+ 32 - 0
docs/acceptance/P3_WP01_EXECUTION_GUIDE.md

@@ -0,0 +1,32 @@
+# P3-WP01 第二业务域企业正式验收执行指南
+
+状态:`ENGINEERING_READY_BLOCKED_EXTERNAL`。本指南及配套机器包已具备执行条件,但未取得真实企业账号、脱敏样本、预生产环境和五方签字;不得据此宣称企业验收通过。
+
+## 执行边界
+
+- 只在企业批准的同一预生产环境、只读数据源和授权账号下执行 P2-WP13-UAT-021~024;四个用例和演练的 `environment_id` 必须与执行上下文一致。
+- 证据只保存脱敏摘要、日志、截图或导出报告;不得写入凭据或原始业务数据。正式文本证据不得把未完成值写成当前 `status/state/状态/当前状态`,JSON 任意字段和 Markdown/CSV 独立单元格也不得保留未完成值;普通历史说明不受此限制。
+- 所有证据相对 `docs/` 保存并计算 SHA-256;本地 `PASS_LOCAL` 不能替代 `ENTERPRISE_PASS`。
+- 执行人与批准人必须不同,五方签署人必须是五个不同的实名主体,禁止代签或预签。
+
+## 企业现场步骤
+
+1. 冻结 `v0.3.0-phase2` 发布物;版本必须精确为 `v0.3.0-phase2`,执行提交必须精确为该标签解引用提交 `95023e59bfb9f00be4ed61f7a8307fac56fbcee9`,同时记录文件名及摘要。
+2. 登记企业、预生产环境、只读数据源、网络区和脱敏样本摘要。
+3. 执行 UAT-021~024,并由独立批准人复核每项证据。
+4. 采集六项指标的分子、分母、统计窗口和批准人,逐项验证阈值。
+5. 完成安装、升级、备份、候选版本回退和 RTO/RPO 演练。
+6. 完成用户与运维培训及实操;如需补训,补训完成后才可关闭。
+7. 由业务与技术双方复核缺陷台账,确认 P0 未关闭数为 0;P1 可清零,或登记数量一致且唯一的 `open_p1_ids`,由不同的业务、技术实名批准人对同一 ID 集合形成正式书面处置决定,`covered_p1_ids` 必须与其精确相等。
+8. 产品、业务、技术、安全、运维五方分别实名签署;签署时间不得早于 UAT、指标窗口、演练、培训、缺陷复核及 P1 书面决定的最晚完成时间。
+9. 将事实写入 `P3_WP01_ENTERPRISE_ACCEPTANCE.json`,把每个证据引用改为 `ENTERPRISE_FORMAL` 并填入真实摘要,然后运行验收器。
+
+## 判定与命令
+
+```bash
+PYTHONPATH=. .venv/bin/python scripts/validate_phase3_wp01_acceptance.py docs/acceptance/P3_WP01_ENTERPRISE_ACCEPTANCE.json
+```
+
+- `ACCEPTED`(退出码 0):全部企业事实、证据和签署满足契约。
+- `BLOCKED`(退出码 3):工程包就绪,但仍缺外部企业输入或执行事实。
+- `FAILED`(退出码 2):存在版本/提交/环境/时序错误、阈值未达、证据越界/缺失/摘要不符、纯占位证据、冒充企业通过、自批、代签、开放 P0、P1 无完整书面决定或执行不完整。

+ 13 - 0
docs/acceptance/P3_WP01_FIVE_PARTY_SIGNOFF.md

@@ -0,0 +1,13 @@
+# P3-WP01 五方签署记录
+
+状态:`ENGINEERING_READY_BLOCKED_EXTERNAL`。本文件没有任何预签名,也不得由同一主体代签多个角色。
+
+| 角色 | 实名 ID | 姓名 | 签署时间(含时区) | 证据路径与 SHA-256 | 状态 |
+|---|---|---|---|---|---|
+| 产品负责人 | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | 未签署 |
+| 业务负责人 | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | 未签署 |
+| 技术负责人 | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | 未签署 |
+| 安全负责人 | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | 未签署 |
+| 运维负责人 | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | 未签署 |
+
+签署前置条件:UAT-021~024 全部企业通过、六项指标达标、预生产演练与两类培训完成、P0 未关闭数为 0,且 P1 已清零或 `covered_p1_ids` 与唯一的 `open_p1_ids` 集合完全一致并取得业务与技术共同书面决定。五个角色必须对应五个不同的实名 ID;每个签署时间不得早于 UAT、指标窗口、演练、培训、缺陷复核及 P1 决定的最晚完成时间,且必须处于验收上下文时间范围内。

+ 1 - 1
docs/phase3/P3_WP00_REQUIREMENTS.json

@@ -480,7 +480,7 @@
         "network",
         "infrastructure"
       ],
-      "engineering_completion_status": "NOT_STARTED",
+      "engineering_completion_status": "ENGINEERING_READY_BLOCKED_EXTERNAL",
       "enterprise_confirmation_status": "TBD_EXTERNAL",
       "dynamic_required_inputs_by_started_wp": {}
     },

+ 29 - 0
docs/training/P3_WP01_TRAINING_EXECUTION_RECORD.md

@@ -0,0 +1,29 @@
+# P3-WP01 企业培训执行记录
+
+状态:`ENGINEERING_READY_BLOCKED_EXTERNAL`。当前文件是空白执行记录,不是培训完成证明。
+
+## 用户培训
+
+| 字段 | 待企业现场填写 |
+|---|---|
+| 企业/预生产环境 | TBD_EXTERNAL |
+| 培训师实名 ID | TBD_EXTERNAL |
+| 参与人实名 ID | TBD_EXTERNAL |
+| 完成时间(含时区) | TBD_EXTERNAL |
+| 实操结果 | TBD_EXTERNAL |
+| 补训要求/完成情况 | TBD_EXTERNAL |
+| 证据路径与 SHA-256 | TBD_EXTERNAL |
+
+## 运维培训
+
+| 字段 | 待企业现场填写 |
+|---|---|
+| 企业/预生产环境 | TBD_EXTERNAL |
+| 培训师实名 ID | TBD_EXTERNAL |
+| 参与人实名 ID | TBD_EXTERNAL |
+| 完成时间(含时区) | TBD_EXTERNAL |
+| 安装、升级、备份、回退实操结果 | TBD_EXTERNAL |
+| 补训要求/完成情况 | TBD_EXTERNAL |
+| 证据路径与 SHA-256 | TBD_EXTERNAL |
+
+只有两类培训均完成实操,且所有必要补训已完成,机器包才可填 `ENTERPRISE_PASS`。

+ 32 - 0
docs/validation/P3_WP01_ACCEPTANCE_EVIDENCE.md

@@ -0,0 +1,32 @@
+# P3-WP01 企业验收证据索引
+
+当前结论:`ENGINEERING_READY_BLOCKED_EXTERNAL`,不是企业验收通过。候选工程基线可执行,但真实企业上下文、现场结果和签署均为外部待办。
+
+## 工程验证
+
+- P3-WP01 验收器契约:53 项通过,覆盖正式通过、外部阻断、失败关闭、畸形类型、非有限数、证据路径/摘要、当前与历史状态、P0/P1、环境及时序门禁。
+- 第二业务域复制、P2-WP13 交接和 P3-WP00 直接受影响契约联合验证:85 项通过,1 项因两侧既有 `__pycache__` 噪声排除。
+- `app/` 与 `deployment/app/`、`migrations/` 与 `deployment/migrations/` 在排除 `__pycache__` 后递归一致;本工作包没有修改业务代码、迁移或发布副本。
+- 新增 Python 文件静态检查通过,补丁空白检查通过。本轮遵循工作包级定向验证策略,未执行全量回归。
+
+以上结果只证明验收工具和第二业务域既有工程链路具备进入企业现场执行的条件,不替代真实企业 UAT。
+
+## 六项指标
+
+| 指标 | 门槛 | 企业实测 | 统计窗口/批准人 | 证据与摘要 |
+|---|---:|---:|---|---|
+| 核心对象接入率 | ≥95% | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+| 增量变化识别准确率 | ≥99% | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+| 责任覆盖率 | ≥95% | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+| 质量问题闭环率 | ≥85% | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+| 事故证据覆盖率 | 100% | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+| 数据产品证据覆盖率 | 100% | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+
+每项实测必须记录分子、分母、计算值、统计窗口和独立批准人。正式证据只保存脱敏内容,引用路径不得越出 `docs/`,摘要必须与文件内容一致。
+
+## 当前外部门禁
+
+- 真实企业、账号、只读数据源与脱敏样本:未提供。
+- 预生产环境及安装/升级/备份/回退/RTO/RPO:未执行。
+- 企业用户与运维培训:未执行。
+- 企业缺陷复核和五方实名签署:未完成。

+ 13 - 0
docs/validation/P3_WP01_PREPRODUCTION_REHEARSAL_RECORD.md

@@ -0,0 +1,13 @@
+# P3-WP01 预生产演练记录
+
+状态:`ENGINEERING_READY_BLOCKED_EXTERNAL`。尚无获批预生产环境,以下项目均未执行。
+
+| 演练项 | 现场状态 | 操作人 | 独立批准人 | 时间 | 证据路径与 SHA-256 |
+|---|---|---|---|---|---|
+| 安装 | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+| 升级 | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+| 备份 | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+| 候选版本回退 | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+| RTO/RPO | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL | TBD_EXTERNAL |
+
+目标:RTO ≤ 30 分钟,RPO ≤ 15 分钟。实际 RTO、RPO 和测量起止点必须由企业现场记录;任一演练未通过都不能签署。

+ 642 - 0
scripts/validate_phase3_wp01_acceptance.py

@@ -0,0 +1,642 @@
+#!/usr/bin/env python3
+"""Fail-closed validator for the P3-WP01 enterprise acceptance package."""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import io
+import json
+import math
+import re
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+BLOCKED = "ENGINEERING_READY_BLOCKED_EXTERNAL"
+COMPLETE = "ENTERPRISE_ACCEPTANCE_COMPLETE"
+EXPECTED_VERSION = "v0.3.0-phase2"
+EXPECTED_COMMIT = "95023e59bfb9f00be4ed61f7a8307fac56fbcee9"
+PLACEHOLDERS = {"", "TBD", "TBD_EXTERNAL", "UNKNOWN", "N/A", "PLACEHOLDER"}
+PURE_EVIDENCE_PLACEHOLDERS = PLACEHOLDERS | {"BLOCKED_EXTERNAL", "PASS_LOCAL"}
+UNFINISHED_STATE_VALUES = {
+    "TBD_EXTERNAL",
+    "BLOCKED_EXTERNAL",
+    "PASS_LOCAL",
+    "ENGINEERING_READY_BLOCKED_EXTERNAL",
+}
+INCOMPLETE_EVIDENCE_VALUES = PURE_EVIDENCE_PLACEHOLDERS | UNFINISHED_STATE_VALUES
+CASE_IDS = {f"P2-WP13-UAT-{number:03d}" for number in range(21, 25)}
+METRIC_TARGETS = {
+    "core_object_onboarding_rate": 95,
+    "incremental_change_accuracy": 99,
+    "responsibility_coverage": 95,
+    "quality_issue_closure_rate": 85,
+    "incident_evidence_coverage": 100,
+    "data_product_evidence_coverage": 100,
+}
+REHEARSAL_STEPS = {"install", "upgrade", "backup", "candidate_rollback", "rto_rpo"}
+TRAINING_IDS = {"user_training", "operations_training"}
+SIGNOFF_ROLES = {
+    "product_owner",
+    "business_owner",
+    "technical_owner",
+    "security_owner",
+    "operations_owner",
+}
+SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
+P1_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
+CURRENT_STATUS_RE = re.compile(
+    r"^\s*(?:[-*]\s*)?(?:\*\*)?(?:status|state|状态|当前状态)(?:\*\*)?\s*[::=]\s*"
+    r"[`'\"*]*\s*(?:TBD_EXTERNAL|BLOCKED_EXTERNAL|PASS_LOCAL|ENGINEERING_READY_BLOCKED_EXTERNAL)"
+    r"\s*[`'\"*]*(?:\s|[。;;,.]|$)",
+    re.IGNORECASE | re.MULTILINE,
+)
+HISTORICAL_ENGLISH_TOKENS = {
+    "history",
+    "historical",
+    "previous",
+    "prior",
+    "superseded",
+}
+HISTORICAL_CHINESE_KEYS = {"原状态", "先前状态", "前一状态"}
+
+
+def _missing(value: Any) -> bool:
+    return value is None or (isinstance(value, str) and value.strip().upper() in PLACEHOLDERS)
+
+
+def _reject_json_constant(value: str) -> None:
+    raise ValueError(f"non-finite JSON number is prohibited: {value}")
+
+
+def _is_finite_number(value: Any) -> bool:
+    return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
+
+
+def _parse_iso_time(value: Any) -> datetime | None:
+    if not isinstance(value, str) or _missing(value):
+        return None
+    try:
+        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+    except ValueError:
+        return None
+    return parsed if parsed.tzinfo is not None else None
+
+
+def _normalized_cell(value: str) -> str:
+    return value.strip().strip("`'\"* ").upper()
+
+
+def _is_historical_key(value: Any) -> bool:
+    if not isinstance(value, str):
+        return False
+    key = value.strip().lower()
+    if key in HISTORICAL_ENGLISH_TOKENS:
+        return True
+    if any(
+        key.startswith(f"{token}{separator}")
+        for token in HISTORICAL_ENGLISH_TOKENS
+        for separator in ("_", "-", " ")
+    ):
+        return True
+    if any(key.endswith(f"{separator}history") for separator in ("_", "-", " ")):
+        return True
+    if key == "历史" or key.startswith("历史"):
+        return True
+    return key in HISTORICAL_CHINESE_KEYS or any(
+        key.startswith(f"{token}{separator}")
+        for token in HISTORICAL_CHINESE_KEYS
+        for separator in ("_", "-", " ")
+    )
+
+
+def _json_contains_incomplete_value(value: Any, *, historical_context: bool = False) -> bool:
+    if isinstance(value, dict):
+        for key, child in value.items():
+            child_is_historical = historical_context or _is_historical_key(key)
+            if _json_contains_incomplete_value(child, historical_context=child_is_historical):
+                return True
+        return False
+    if isinstance(value, list):
+        return any(
+            _json_contains_incomplete_value(child, historical_context=historical_context)
+            for child in value
+        )
+    return (
+        not historical_context
+        and isinstance(value, str)
+        and value.strip().upper() in INCOMPLETE_EVIDENCE_VALUES
+    )
+
+
+def _is_separator_row(cells: list[str]) -> bool:
+    return bool(cells) and all(not cell or re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in cells)
+
+
+def _table_contains_current_placeholder(rows: list[list[str]]) -> bool:
+    rows = [[cell.strip() for cell in row] for row in rows if any(cell.strip() for cell in row)]
+    if not rows:
+        return False
+    header = rows[0]
+    historical_columns = {
+        index for index, cell in enumerate(header) if _is_historical_key(_normalized_cell(cell))
+    }
+    for cells in rows:
+        if _is_separator_row(cells):
+            continue
+        if cells and _is_historical_key(_normalized_cell(cells[0])):
+            continue
+        for index, cell in enumerate(cells):
+            if (
+                index not in historical_columns
+                and _normalized_cell(cell) in INCOMPLETE_EVIDENCE_VALUES
+            ):
+                return True
+    return False
+
+
+def _is_placeholder_evidence(evidence_bytes: bytes) -> bool:
+    """Reject structured current-state placeholders without guessing about binary evidence."""
+    try:
+        evidence_text = evidence_bytes.decode("utf-8")
+    except UnicodeDecodeError:
+        return False
+    if evidence_text.strip().upper() in INCOMPLETE_EVIDENCE_VALUES:
+        return True
+    try:
+        parsed_json = json.loads(evidence_text)
+    except json.JSONDecodeError:
+        parsed_json = None
+    if parsed_json is not None and _json_contains_incomplete_value(parsed_json):
+        return True
+    if CURRENT_STATUS_RE.search(evidence_text):
+        return True
+    markdown_rows = [line.split("|")[1:-1] if line.strip().startswith("|") and line.strip().endswith("|") else line.split("|") for line in evidence_text.splitlines() if "|" in line]
+    if _table_contains_current_placeholder(markdown_rows):
+        return True
+    try:
+        csv_rows = list(csv.reader(io.StringIO(evidence_text)))
+    except csv.Error:
+        csv_rows = []
+    return any(len(row) > 1 for row in csv_rows) and _table_contains_current_placeholder(csv_rows)
+
+
+def _records_by_id(records: Any, key: str, expected: set[str], failures: list[str], label: str) -> dict[str, dict]:
+    if not isinstance(records, list):
+        failures.append(f"{label} must be a list")
+        return {}
+    mapped: dict[str, dict] = {}
+    for record in records:
+        if (
+            not isinstance(record, dict)
+            or not isinstance(record.get(key), str)
+            or _missing(record.get(key))
+        ):
+            failures.append(f"{label} contains an invalid record")
+            continue
+        record_id = record[key]
+        if record_id in mapped:
+            failures.append(f"{label} contains duplicate {record_id}")
+        mapped[record_id] = record
+    if set(mapped) != expected:
+        failures.append(f"{label} ids must be exactly {sorted(expected)}")
+    return mapped
+
+
+def _validate_acceptance_bundle(package_path: Path, evidence_root: Path | None = None) -> dict[str, Any]:
+    package_path = Path(package_path).resolve()
+    failures: list[str] = []
+    blockers: list[str] = []
+    try:
+        payload = json.loads(
+            package_path.read_text(encoding="utf-8"), parse_constant=_reject_json_constant
+        )
+    except (OSError, json.JSONDecodeError, ValueError) as exc:
+        return {"status": "FAILED", "declared_status": None, "blockers": [], "failures": [f"cannot read package: {exc}"]}
+    if not isinstance(payload, dict):
+        return {"status": "FAILED", "declared_status": None, "blockers": [], "failures": ["package root must be an object"]}
+
+    declared = payload.get("declared_status")
+    if not isinstance(declared, str) or declared not in {BLOCKED, COMPLETE}:
+        failures.append(f"invalid declared_status: {declared!r}")
+    enterprise_claim = declared == COMPLETE
+    root = Path(evidence_root).resolve() if evidence_root else (
+        package_path.parent.parent.resolve() if package_path.parent.name == "acceptance" else package_path.parent.resolve()
+    )
+
+    def pending_or_fail(label: str) -> None:
+        message = f"{label} is missing or placeholder"
+        (failures if enterprise_claim else blockers).append(message)
+
+    def require(value: Any, label: str) -> bool:
+        if _missing(value):
+            pending_or_fail(label)
+            return False
+        return True
+
+    def require_string(value: Any, label: str) -> bool:
+        if value is None or (
+            isinstance(value, str)
+            and value.strip().upper() in INCOMPLETE_EVIDENCE_VALUES
+        ):
+            pending_or_fail(label)
+            return False
+        if not isinstance(value, str) or not value.strip():
+            failures.append(f"{label} must be a non-empty, non-placeholder string")
+            return False
+        return True
+
+    def object_field(container: dict[str, Any], key: str, label: str) -> dict[str, Any]:
+        value = container.get(key)
+        if not isinstance(value, dict):
+            failures.append(f"{label} must be an object")
+            return {}
+        return value
+
+    def require_time(value: Any, label: str) -> datetime | None:
+        if not require_string(value, label):
+            return None
+        parsed = _parse_iso_time(value)
+        if parsed is None:
+            failures.append(f"{label} must be an ISO-8601 timestamp with timezone")
+        return parsed
+
+    def validate_refs(refs: Any, label: str) -> None:
+        if not isinstance(refs, list):
+            failures.append(f"{label}.evidence_refs must be a list")
+            return
+        if not refs:
+            pending_or_fail(f"{label}.evidence_refs")
+            return
+        for index, ref in enumerate(refs):
+            ref_label = f"{label}.evidence_refs[{index}]"
+            if not isinstance(ref, dict):
+                failures.append(f"{ref_label} must be an object")
+                continue
+            raw_path = ref.get("path")
+            if not require_string(raw_path, f"{ref_label}.path"):
+                continue
+            relative = Path(raw_path)
+            if relative.is_absolute() or ".." in relative.parts:
+                failures.append(f"{ref_label} evidence path escapes evidence root")
+                continue
+            resolved = (root / relative).resolve()
+            try:
+                resolved.relative_to(root)
+            except ValueError:
+                failures.append(f"{ref_label} evidence path escapes evidence root")
+                continue
+            digest = ref.get("sha256")
+            level = ref.get("evidence_level")
+            if not resolved.is_file():
+                if enterprise_claim or not _missing(digest):
+                    failures.append(f"{ref_label} missing evidence file: {raw_path}")
+                else:
+                    blockers.append(f"{ref_label} external evidence file is not yet captured")
+            if require_string(digest, f"{ref_label}.sha256") and not SHA256_RE.fullmatch(digest):
+                failures.append(f"{ref_label}.sha256 must be a lowercase SHA-256 digest")
+            elif isinstance(digest, str) and SHA256_RE.fullmatch(digest) and resolved.is_file():
+                evidence_bytes = resolved.read_bytes()
+                actual = hashlib.sha256(evidence_bytes).hexdigest()
+                if actual != digest:
+                    failures.append(f"{ref_label} digest mismatch")
+                if _is_placeholder_evidence(evidence_bytes):
+                    failures.append(f"{ref_label} placeholder evidence cannot satisfy enterprise acceptance")
+            if require_string(level, f"{ref_label}.evidence_level") and level != "ENTERPRISE_FORMAL":
+                failures.append(f"{ref_label} evidence level {level!r} is not ENTERPRISE_FORMAL")
+
+    if payload.get("schema_version") != "1.0" or payload.get("work_package") != "P3-WP01":
+        failures.append("schema_version/work_package contract mismatch")
+    source_cases = payload.get("source_cases")
+    if (
+        not isinstance(source_cases, list)
+        or any(not isinstance(item, str) or _missing(item) for item in source_cases)
+        or set(source_cases) != CASE_IDS
+    ):
+        failures.append("source_cases must contain UAT-021 through UAT-024 exactly")
+
+    context = object_field(payload, "execution_context", "execution_context")
+    for field in ("enterprise_name", "environment_id"):
+        require_string(context.get(field), f"execution_context.{field}")
+    if require_string(context.get("environment_type"), "execution_context.environment_type") and context.get("environment_type") != "PREPRODUCTION":
+        failures.append("execution_context.environment_type must be PREPRODUCTION")
+    commit = context.get("executed_commit")
+    if require_string(commit, "execution_context.executed_commit") and commit != EXPECTED_COMMIT:
+        failures.append(f"execution_context.executed_commit must equal {EXPECTED_COMMIT}")
+    context_started = require_time(context.get("started_at"), "execution_context.started_at")
+    context_completed = require_time(context.get("completed_at"), "execution_context.completed_at")
+    if context_started and context_completed and context_started > context_completed:
+        failures.append("execution_context.started_at must not be after completed_at")
+
+    completion_events: list[tuple[str, datetime]] = []
+
+    def bind_time(value: Any, label: str, *, completion_event: bool = False) -> datetime | None:
+        parsed = require_time(value, label)
+        if parsed and context_started and parsed < context_started:
+            failures.append(f"{label} must be within execution_context time range")
+        if parsed and context_completed and parsed > context_completed:
+            failures.append(f"{label} must be within execution_context time range")
+        if parsed and completion_event:
+            completion_events.append((label, parsed))
+        return parsed
+
+    source = object_field(payload, "source", "source")
+    for field in ("source_id", "source_type", "network_zone"):
+        require_string(source.get(field), f"source.{field}")
+    if source.get("readonly") is None:
+        pending_or_fail("source.readonly")
+    elif source.get("readonly") is not True:
+        failures.append("source.readonly must be true")
+    if source.get("credentials_in_package") is not False:
+        failures.append("source.credentials_in_package must be false")
+
+    sample = object_field(payload, "sample_summary", "sample_summary")
+    if require_string(sample.get("classification"), "sample_summary.classification") and sample.get("classification") != "DESENSITIZED_ENTERPRISE_SAMPLE":
+        failures.append("sample_summary.classification must be DESENSITIZED_ENTERPRISE_SAMPLE")
+    for field in ("object_count", "row_count"):
+        value = sample.get(field)
+        if not require(value, f"sample_summary.{field}"):
+            continue
+        if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
+            failures.append(f"sample_summary.{field} must be a positive integer")
+    sample_digest = sample.get("sha256")
+    if require_string(sample_digest, "sample_summary.sha256") and not SHA256_RE.fullmatch(sample_digest):
+        failures.append("sample_summary.sha256 must be a lowercase SHA-256 digest")
+    if sample.get("raw_data_in_evidence") is not False:
+        failures.append("sample_summary.raw_data_in_evidence must be false")
+
+    artifact = object_field(payload, "release_artifact", "release_artifact")
+    version = artifact.get("version")
+    if require_string(version, "release_artifact.version") and version != EXPECTED_VERSION:
+        failures.append(f"release_artifact.version must equal {EXPECTED_VERSION}")
+    require_string(artifact.get("artifact_name"), "release_artifact.artifact_name")
+    artifact_digest = artifact.get("sha256")
+    if require_string(artifact_digest, "release_artifact.sha256") and not SHA256_RE.fullmatch(artifact_digest):
+        failures.append("release_artifact.sha256 must be a lowercase SHA-256 digest")
+    validate_refs(artifact.get("evidence_refs"), "release_artifact")
+
+    cases = _records_by_id(payload.get("uat_cases"), "id", CASE_IDS, failures, "uat_cases")
+    for case_id, case in cases.items():
+        result = case.get("result")
+        if result == "PASS_LOCAL":
+            failures.append(f"{case_id} PASS_LOCAL cannot satisfy enterprise acceptance")
+            result_valid = False
+        else:
+            result_valid = require_string(result, f"{case_id}.result")
+        if result_valid and result != "ENTERPRISE_PASS":
+            if enterprise_claim:
+                failures.append(f"{case_id} must be ENTERPRISE_PASS")
+            else:
+                blockers.append(f"{case_id} awaits enterprise execution")
+        for field in ("environment_id", "executed_by_person_id", "approved_by_person_id"):
+            require_string(case.get(field), f"{case_id}.{field}")
+        if isinstance(case.get("environment_id"), str) and isinstance(context.get("environment_id"), str) and case.get("environment_id") != context.get("environment_id"):
+            failures.append(f"{case_id}.environment_id must match execution_context.environment_id")
+        bind_time(case.get("executed_at"), f"{case_id}.executed_at", completion_event=True)
+        if case.get("executed_by_person_id") and case.get("executed_by_person_id") == case.get("approved_by_person_id"):
+            failures.append(f"{case_id} self-approval is prohibited")
+        validate_refs(case.get("evidence_refs"), case_id)
+
+    metrics = _records_by_id(payload.get("metrics"), "id", set(METRIC_TARGETS), failures, "metrics")
+    for metric_id, metric in metrics.items():
+        target = METRIC_TARGETS[metric_id]
+        comparator_valid = require_string(metric.get("comparator"), f"{metric_id}.comparator")
+        unit_valid = require_string(metric.get("unit"), f"{metric_id}.unit")
+        target_value = metric.get("target")
+        target_ready = require(target_value, f"{metric_id}.target")
+        target_valid = target_ready and _is_finite_number(target_value)
+        if target_ready and not target_valid:
+            failures.append(f"{metric_id}.target must be a finite number")
+        if not (comparator_valid and unit_valid and target_valid) or metric.get("comparator") != "GTE" or target_value != target or metric.get("unit") != "percent":
+            failures.append(f"{metric_id} threshold contract must be GTE {target} percent")
+        metric_status_valid = require_string(metric.get("status"), f"{metric_id}.status")
+        if metric_status_valid and metric.get("status") != "ENTERPRISE_PASS":
+            (failures if enterprise_claim else blockers).append(f"{metric_id} awaits enterprise measurement")
+        numerator, denominator, value = metric.get("numerator"), metric.get("denominator"), metric.get("value")
+        values_ready = True
+        for item, field in ((numerator, "numerator"), (denominator, "denominator"), (value, "value")):
+            if not require(item, f"{metric_id}.{field}"):
+                values_ready = False
+            elif not _is_finite_number(item):
+                failures.append(f"{metric_id}.{field} must be a finite number")
+                values_ready = False
+        if values_ready:
+            if numerator < 0 or denominator <= 0 or value < 0:
+                failures.append(f"{metric_id} metric values are invalid")
+            else:
+                calculated = numerator / denominator * 100
+                if abs(calculated - value) > 1e-6:
+                    failures.append(f"{metric_id} value does not match numerator/denominator")
+                if value < target:
+                    failures.append(f"{metric_id} threshold {target}% is not met")
+        window_start = bind_time(metric.get("window_start"), f"{metric_id}.window_start")
+        window_end = bind_time(metric.get("window_end"), f"{metric_id}.window_end", completion_event=True)
+        if window_start and window_end and window_start > window_end:
+            failures.append(f"{metric_id}.window_start must not be after window_end")
+        require_string(metric.get("approved_by_person_id"), f"{metric_id}.approved_by_person_id")
+        validate_refs(metric.get("evidence_refs"), metric_id)
+
+    rehearsal = object_field(payload, "preproduction_rehearsal", "preproduction_rehearsal")
+    rehearsal_status_valid = require_string(rehearsal.get("status"), "preproduction_rehearsal.status")
+    if rehearsal_status_valid and rehearsal.get("status") != "ENTERPRISE_PASS":
+        (failures if enterprise_claim else blockers).append("preproduction rehearsal is incomplete")
+    for field in ("environment_id", "executed_by_person_id", "approved_by_person_id"):
+        require_string(rehearsal.get(field), f"preproduction_rehearsal.{field}")
+    if isinstance(rehearsal.get("environment_id"), str) and isinstance(context.get("environment_id"), str) and rehearsal.get("environment_id") != context.get("environment_id"):
+        failures.append("preproduction_rehearsal.environment_id must match execution_context.environment_id")
+    bind_time(rehearsal.get("executed_at"), "preproduction_rehearsal.executed_at", completion_event=True)
+    if rehearsal.get("executed_by_person_id") and rehearsal.get("executed_by_person_id") == rehearsal.get("approved_by_person_id"):
+        failures.append("preproduction rehearsal self-approval is prohibited")
+    steps = _records_by_id(rehearsal.get("steps"), "id", REHEARSAL_STEPS, failures, "preproduction rehearsal steps")
+    for step_id, step in steps.items():
+        step_status_valid = require_string(step.get("status"), f"preproduction rehearsal step {step_id}.status")
+        if step_status_valid and step.get("status") != "ENTERPRISE_PASS":
+            (failures if enterprise_claim else blockers).append(f"preproduction rehearsal step {step_id} is incomplete")
+    for observed, target_field in (("rto_minutes", "rto_target_minutes"), ("rpo_minutes", "rpo_target_minutes")):
+        target_value = rehearsal.get(target_field)
+        target_valid = require(target_value, f"preproduction_rehearsal.{target_field}")
+        if target_valid and (not _is_finite_number(target_value) or target_value <= 0):
+            failures.append(f"preproduction_rehearsal.{target_field} must be a positive finite number")
+            target_valid = False
+        value = rehearsal.get(observed)
+        value_valid = require(value, f"preproduction_rehearsal.{observed}")
+        if value_valid and (not _is_finite_number(value) or value < 0):
+            failures.append(f"preproduction_rehearsal.{observed} must be a non-negative finite number")
+            value_valid = False
+        if target_valid and value_valid and value > target_value:
+            failures.append(f"preproduction rehearsal {observed} exceeds its target")
+    validate_refs(rehearsal.get("evidence_refs"), "preproduction_rehearsal")
+
+    trainings = _records_by_id(payload.get("training_records"), "id", TRAINING_IDS, failures, "training_records")
+    for training_id, record in trainings.items():
+        training_status_valid = require_string(record.get("status"), f"training {training_id}.status")
+        if training_status_valid and record.get("status") != "ENTERPRISE_PASS":
+            (failures if enterprise_claim else blockers).append(f"training {training_id} is incomplete")
+        participants = record.get("participants")
+        if not isinstance(participants, list):
+            failures.append(f"training {training_id}.participants must be a list")
+        elif not participants:
+            pending_or_fail(f"training {training_id}.participants")
+        else:
+            for index, participant in enumerate(participants):
+                require_string(participant, f"training {training_id}.participants[{index}]")
+        require_string(record.get("trainer_person_id"), f"training {training_id}.trainer_person_id")
+        bind_time(record.get("completed_at"), f"training {training_id}.completed_at", completion_event=True)
+        if record.get("exercise_passed") is not True:
+            (failures if enterprise_claim else blockers).append(f"training {training_id} exercise is incomplete")
+        if record.get("retraining_required") is None:
+            pending_or_fail(f"training {training_id}.retraining_required")
+        elif not isinstance(record.get("retraining_required"), bool):
+            failures.append(f"training {training_id}.retraining_required must be boolean")
+        if record.get("retraining_completed") is None:
+            pending_or_fail(f"training {training_id}.retraining_completed")
+        elif not isinstance(record.get("retraining_completed"), bool):
+            failures.append(f"training {training_id}.retraining_completed must be boolean")
+        if record.get("retraining_required") is True and record.get("retraining_completed") is not True:
+            failures.append(f"training {training_id} required retraining is incomplete")
+        validate_refs(record.get("evidence_refs"), f"training {training_id}")
+
+    defects = object_field(payload, "defect_gate", "defect_gate")
+    defect_status_valid = require_string(defects.get("status"), "defect_gate.status")
+    if defect_status_valid and defects.get("status") != "ENTERPRISE_PASS":
+        (failures if enterprise_claim else blockers).append("defect gate awaits enterprise review")
+    p0_open = defects.get("p0_open")
+    if require(p0_open, "defect_gate.p0_open") and (
+        not isinstance(p0_open, int) or isinstance(p0_open, bool) or p0_open != 0
+    ):
+        failures.append("defect_gate.p0_open must be 0")
+    p1_open = defects.get("p1_open")
+    p1_ready = require(p1_open, "defect_gate.p1_open")
+    if p1_ready and (not isinstance(p1_open, int) or isinstance(p1_open, bool) or p1_open < 0):
+        failures.append("defect_gate.p1_open must be a non-negative integer")
+        p1_ready = False
+    open_p1_ids = defects.get("open_p1_ids")
+
+    def valid_nonnegative_count(value: Any, label: str) -> bool:
+        if not isinstance(value, int) or isinstance(value, bool) or value < 0:
+            failures.append(f"{label} must be a non-negative integer")
+            return False
+        return True
+
+    def valid_p1_ids(value: Any, label: str) -> bool:
+        if not isinstance(value, list):
+            failures.append(f"{label} must be a list")
+            return False
+        if any(not isinstance(item, str) or _missing(item) or not P1_ID_RE.fullmatch(item) for item in value):
+            failures.append(f"{label} contains an invalid or placeholder P1 id")
+            return False
+        if len(value) != len(set(value)):
+            failures.append(f"{label} must contain unique P1 ids")
+            return False
+        return True
+
+    disposition = object_field(defects, "p1_disposition", "defect_gate.p1_disposition")
+    disposition_status = disposition.get("status")
+    if not require_string(disposition_status, "defect_gate.p1_disposition.status"):
+        pass
+    elif p1_ready and p1_open == 0:
+        if open_p1_ids != []:
+            failures.append("defect_gate.open_p1_ids must be empty when p1_open is 0")
+        if disposition_status != "NOT_REQUIRED":
+            failures.append("defect_gate.p1_disposition.status must be NOT_REQUIRED when p1_open is 0")
+        covered_count = disposition.get("covered_p1_count")
+        if not valid_nonnegative_count(covered_count, "defect_gate.p1_disposition.covered_p1_count") or covered_count != 0:
+            failures.append("defect_gate.p1_disposition.covered_p1_count must be 0 when p1_open is 0")
+        if disposition.get("covered_p1_ids") != []:
+            failures.append("defect_gate.p1_disposition.covered_p1_ids must be empty when p1_open is 0")
+    elif p1_ready and p1_open > 0:
+        open_ids_valid = valid_p1_ids(open_p1_ids, "defect_gate.open_p1_ids")
+        if open_ids_valid and len(open_p1_ids) != p1_open:
+            failures.append("defect_gate.open_p1_ids count must equal p1_open")
+        if disposition_status != "WRITTEN_DECISION_APPROVED":
+            failures.append("open P1 defects require WRITTEN_DECISION_APPROVED disposition")
+        covered_count = disposition.get("covered_p1_count")
+        if not valid_nonnegative_count(covered_count, "defect_gate.p1_disposition.covered_p1_count") or covered_count != p1_open:
+            failures.append("P1 written decision must cover every open P1 defect")
+        covered_p1_ids = disposition.get("covered_p1_ids")
+        covered_ids_valid = valid_p1_ids(covered_p1_ids, "defect_gate.p1_disposition.covered_p1_ids")
+        if open_ids_valid and covered_ids_valid and set(covered_p1_ids) != set(open_p1_ids):
+            failures.append("P1 written decision covered_p1_ids must exactly match open_p1_ids")
+        disposition_business = disposition.get("business_person_id")
+        disposition_technical = disposition.get("technical_person_id")
+        require_string(disposition_business, "defect_gate.p1_disposition.business_person_id")
+        require_string(disposition_technical, "defect_gate.p1_disposition.technical_person_id")
+        if disposition_business and disposition_business == disposition_technical:
+            failures.append("P1 written decision business and technical approvers must be distinct")
+        bind_time(disposition.get("decided_at"), "defect_gate.p1_disposition.decided_at", completion_event=True)
+        validate_refs(disposition.get("evidence_refs"), "defect_gate.p1_disposition")
+    business_reviewer = defects.get("reviewed_by_business_person_id")
+    technical_reviewer = defects.get("reviewed_by_technical_person_id")
+    require_string(business_reviewer, "defect_gate.reviewed_by_business_person_id")
+    require_string(technical_reviewer, "defect_gate.reviewed_by_technical_person_id")
+    if business_reviewer and business_reviewer == technical_reviewer:
+        failures.append("defect gate business and technical reviewers must be distinct")
+    bind_time(defects.get("reviewed_at"), "defect_gate.reviewed_at", completion_event=True)
+    validate_refs(defects.get("evidence_refs"), "defect_gate")
+
+    signoffs = _records_by_id(payload.get("signoffs"), "role", SIGNOFF_ROLES, failures, "signoffs")
+    signer_ids: list[str] = []
+    signoff_times: list[tuple[str, datetime]] = []
+    for role, signoff in signoffs.items():
+        signoff_status_valid = require_string(signoff.get("status"), f"{role}.status")
+        if signoff_status_valid and signoff.get("status") != "SIGNED":
+            (failures if enterprise_claim else blockers).append(f"{role} signoff is not signed")
+        if require_string(signoff.get("person_id"), f"{role}.person_id"):
+            signer_ids.append(signoff["person_id"])
+        require_string(signoff.get("person_name"), f"{role}.person_name")
+        signed_at = bind_time(signoff.get("signed_at"), f"{role}.signed_at")
+        if signed_at:
+            signoff_times.append((role, signed_at))
+        validate_refs(signoff.get("evidence_refs"), role)
+    if len(signer_ids) != len(set(signer_ids)):
+        failures.append("five-party proxy signing is prohibited; each role needs a distinct person_id")
+    if completion_events:
+        latest_label, latest_completion = max(completion_events, key=lambda item: item[1])
+        for role, signed_at in signoff_times:
+            if signed_at < latest_completion:
+                failures.append(f"{role}.signed_at must not precede completion event {latest_label}")
+
+    failures = list(dict.fromkeys(failures))
+    blockers = list(dict.fromkeys(blockers))
+    if failures:
+        status = "FAILED"
+    elif blockers:
+        status = "FAILED" if enterprise_claim else "BLOCKED"
+        if enterprise_claim:
+            failures = [f"acceptance claim is incomplete: {blocker}" for blocker in blockers]
+            blockers = []
+    elif enterprise_claim:
+        status = "ACCEPTED"
+    else:
+        status = "FAILED"
+        failures = ["blocked package contains no external blockers; declared status is inconsistent"]
+    return {"status": status, "declared_status": declared, "blockers": blockers, "failures": failures}
+
+
+def validate_acceptance_bundle(package_path: Path, evidence_root: Path | None = None) -> dict[str, Any]:
+    """Validate a bundle and convert expected malformed-input errors into FAILED."""
+    try:
+        return _validate_acceptance_bundle(package_path, evidence_root)
+    except (KeyError, OverflowError, TypeError, ValueError) as exc:
+        return {
+            "status": "FAILED",
+            "declared_status": None,
+            "blockers": [],
+            "failures": [f"malformed acceptance package: {type(exc).__name__}: {exc}"],
+        }
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("package", type=Path, help="enterprise acceptance JSON package")
+    parser.add_argument("--evidence-root", type=Path)
+    args = parser.parse_args()
+    result = validate_acceptance_bundle(args.package, args.evidence_root)
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+    return {"ACCEPTED": 0, "FAILED": 2, "BLOCKED": 3}[result["status"]]
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 536 - 0
tests/test_phase3_wp01_enterprise_acceptance_contract.py

@@ -0,0 +1,536 @@
+from __future__ import annotations
+
+import copy
+import hashlib
+import importlib.util
+import json
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+TEMPLATE = ROOT / "docs/acceptance/P3_WP01_ENTERPRISE_ACCEPTANCE.json"
+VALIDATOR_PATH = ROOT / "scripts/validate_phase3_wp01_acceptance.py"
+
+SPEC = importlib.util.spec_from_file_location("phase3_wp01_validator", VALIDATOR_PATH)
+assert SPEC and SPEC.loader
+validator = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(validator)
+
+
+def _load_template() -> dict:
+    return json.loads(TEMPLATE.read_text(encoding="utf-8"))
+
+
+def _bind_evidence(value: object, root: Path, counter: list[int]) -> None:
+    if isinstance(value, dict):
+        for key, child in value.items():
+            if key == "evidence_refs" and isinstance(child, list):
+                for reference in child:
+                    counter[0] += 1
+                    name = f"evidence-{counter[0]:03d}.json"
+                    content = json.dumps(
+                        {"evidence_id": counter[0], "enterprise_formal": True},
+                        sort_keys=True,
+                    ).encode()
+                    (root / name).write_bytes(content)
+                    reference.update(
+                        {
+                            "path": name,
+                            "sha256": hashlib.sha256(content).hexdigest(),
+                            "evidence_level": "ENTERPRISE_FORMAL",
+                        }
+                    )
+            else:
+                _bind_evidence(child, root, counter)
+    elif isinstance(value, list):
+        for child in value:
+            _bind_evidence(child, root, counter)
+
+
+def _accepted_bundle(tmp_path: Path) -> Path:
+    payload = copy.deepcopy(_load_template())
+    payload["declared_status"] = "ENTERPRISE_ACCEPTANCE_COMPLETE"
+    payload["execution_context"].update(
+        {
+            "enterprise_name": "Synthetic Enterprise",
+            "environment_id": "preprod-synthetic-01",
+            "environment_type": "PREPRODUCTION",
+            "executed_commit": "95023e59bfb9f00be4ed61f7a8307fac56fbcee9",
+            "started_at": "2026-08-03T09:00:00+08:00",
+            "completed_at": "2026-08-05T18:00:00+08:00",
+        }
+    )
+    payload["source"].update(
+        {
+            "source_id": "enterprise-source-synthetic-01",
+            "source_type": "POSTGRESQL",
+            "readonly": True,
+            "credentials_in_package": False,
+            "network_zone": "enterprise-preproduction",
+        }
+    )
+    payload["sample_summary"].update(
+        {
+            "classification": "DESENSITIZED_ENTERPRISE_SAMPLE",
+            "object_count": 100,
+            "row_count": 1000,
+            "sha256": hashlib.sha256(b"synthetic-enterprise-sample-summary").hexdigest(),
+            "raw_data_in_evidence": False,
+        }
+    )
+    payload["release_artifact"].update(
+        {
+            "version": "v0.3.0-phase2",
+            "artifact_name": "dataops-phase2-synthetic.tar.gz",
+            "sha256": hashlib.sha256(b"synthetic-release-artifact").hexdigest(),
+        }
+    )
+
+    for index, case in enumerate(payload["uat_cases"], start=1):
+        case.update(
+            {
+                "result": "ENTERPRISE_PASS",
+                "environment_id": "preprod-synthetic-01",
+                "executed_at": f"2026-08-03T1{index}:00:00+08:00",
+                "executed_by_person_id": f"executor-{index}",
+                "approved_by_person_id": f"approver-{index}",
+            }
+        )
+
+    metric_values = {
+        "core_object_onboarding_rate": (95, 100),
+        "incremental_change_accuracy": (99, 100),
+        "responsibility_coverage": (95, 100),
+        "quality_issue_closure_rate": (85, 100),
+        "incident_evidence_coverage": (10, 10),
+        "data_product_evidence_coverage": (10, 10),
+    }
+    for metric in payload["metrics"]:
+        numerator, denominator = metric_values[metric["id"]]
+        metric.update(
+            {
+                "status": "ENTERPRISE_PASS",
+                "numerator": numerator,
+                "denominator": denominator,
+                "value": numerator / denominator * 100,
+                "window_start": "2026-08-03T09:00:00+08:00",
+                "window_end": "2026-08-03T18:00:00+08:00",
+                "approved_by_person_id": f"metric-approver-{metric['id']}",
+            }
+        )
+
+    rehearsal = payload["preproduction_rehearsal"]
+    rehearsal.update(
+        {
+            "status": "ENTERPRISE_PASS",
+            "environment_id": "preprod-synthetic-01",
+            "executed_at": "2026-08-03T14:00:00+08:00",
+            "executed_by_person_id": "preprod-operator-1",
+            "approved_by_person_id": "preprod-approver-1",
+            "rto_minutes": 25,
+            "rpo_minutes": 10,
+        }
+    )
+    for step in rehearsal["steps"]:
+        step["status"] = "ENTERPRISE_PASS"
+
+    for index, record in enumerate(payload["training_records"], start=1):
+        record.update(
+            {
+                "status": "ENTERPRISE_PASS",
+                "participants": [f"trainee-{index}-1", f"trainee-{index}-2"],
+                "trainer_person_id": f"trainer-{index}",
+                "completed_at": f"2026-08-04T1{index}:00:00+08:00",
+                "exercise_passed": True,
+                "retraining_required": False,
+                "retraining_completed": False,
+            }
+        )
+
+    payload["defect_gate"].update(
+        {
+            "status": "ENTERPRISE_PASS",
+            "p0_open": 0,
+            "p1_open": 0,
+            "reviewed_by_business_person_id": "business-defect-reviewer",
+            "reviewed_by_technical_person_id": "technical-defect-reviewer",
+            "reviewed_at": "2026-08-04T15:00:00+08:00",
+        }
+    )
+    payload["defect_gate"]["p1_disposition"].update(
+        {"status": "NOT_REQUIRED", "covered_p1_count": 0}
+    )
+
+    for index, signoff in enumerate(payload["signoffs"], start=1):
+        signoff.update(
+            {
+                "status": "SIGNED",
+                "person_id": f"signer-{index}",
+                "person_name": f"Synthetic Signer {index}",
+                "signed_at": f"2026-08-05T1{index}:00:00+08:00",
+            }
+        )
+
+    _bind_evidence(payload, tmp_path, [0])
+    package = tmp_path / "enterprise-acceptance.json"
+    package.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+    return package
+
+
+def _replace_first_case_evidence(package: Path, content: bytes) -> None:
+    payload = json.loads(package.read_text())
+    reference = payload["uat_cases"][0]["evidence_refs"][0]
+    (package.parent / reference["path"]).write_bytes(content)
+    reference["sha256"] = hashlib.sha256(content).hexdigest()
+    package.write_text(json.dumps(payload), encoding="utf-8")
+
+
+def _set_nested(payload: object, path: tuple[object, ...], value: object) -> None:
+    current = payload
+    for part in path[:-1]:
+        current = current[part]  # type: ignore[index]
+    current[path[-1]] = value  # type: ignore[index]
+
+
+def test_checked_in_template_is_engineering_ready_and_blocked_external() -> None:
+    result = validator.validate_acceptance_bundle(TEMPLATE)
+    assert result["status"] == "BLOCKED"
+    assert result["declared_status"] == "ENGINEERING_READY_BLOCKED_EXTERNAL"
+    assert result["blockers"]
+    assert not result["failures"]
+
+
+def test_complete_synthetic_enterprise_bundle_is_accepted(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    result = validator.validate_acceptance_bundle(package)
+    assert result == {
+        "status": "ACCEPTED",
+        "declared_status": "ENTERPRISE_ACCEPTANCE_COMPLETE",
+        "blockers": [],
+        "failures": [],
+    }
+
+
+def test_low_metric_and_local_pass_cannot_be_accepted(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload["metrics"][0]["numerator"] = 94
+    payload["metrics"][0]["value"] = 94
+    payload["uat_cases"][0]["result"] = "PASS_LOCAL"
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("threshold" in failure for failure in result["failures"])
+    assert any("PASS_LOCAL" in failure for failure in result["failures"])
+
+
+@pytest.mark.parametrize("invalid_path", ["../escape.json", "/tmp/absolute.json"])
+def test_evidence_path_escape_is_rejected(tmp_path: Path, invalid_path: str) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload["uat_cases"][0]["evidence_refs"][0]["path"] = invalid_path
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("evidence path" in failure for failure in result["failures"])
+
+
+def test_missing_or_digest_mismatched_evidence_is_rejected(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    first = payload["uat_cases"][0]["evidence_refs"][0]
+    (tmp_path / first["path"]).unlink()
+    payload["uat_cases"][1]["evidence_refs"][0]["sha256"] = "0" * 64
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("missing evidence" in failure for failure in result["failures"])
+    assert any("digest mismatch" in failure for failure in result["failures"])
+
+
+def test_placeholder_file_cannot_pretend_to_be_enterprise_evidence(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    reference = payload["uat_cases"][0]["evidence_refs"][0]
+    content = b"TBD_EXTERNAL"
+    (tmp_path / reference["path"]).write_bytes(content)
+    reference["sha256"] = hashlib.sha256(content).hexdigest()
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("placeholder evidence" in failure for failure in result["failures"])
+
+
+@pytest.mark.parametrize(
+    "content",
+    [
+        b"status: TBD_EXTERNAL\nThe execution steps are documented below.",
+        json.dumps({"status": "TBD_EXTERNAL", "enterprise_formal": True}).encode(),
+        b"| field | value |\n|---|---|\n| result | TBD_EXTERNAL |",
+        b"field,value\nresult,BLOCKED_EXTERNAL",
+    ],
+)
+def test_structured_unfinished_state_cannot_be_formal_evidence(
+    tmp_path: Path, content: bytes
+) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    reference = payload["uat_cases"][0]["evidence_refs"][0]
+    (tmp_path / reference["path"]).write_bytes(content)
+    reference["sha256"] = hashlib.sha256(content).hexdigest()
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("placeholder evidence" in failure for failure in result["failures"])
+
+
+def test_checked_in_execution_guide_cannot_be_disguised_as_formal_evidence(
+    tmp_path: Path,
+) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    reference = payload["uat_cases"][0]["evidence_refs"][0]
+    content = (ROOT / "docs/acceptance/P3_WP01_EXECUTION_GUIDE.md").read_bytes()
+    (tmp_path / reference["path"]).write_bytes(content)
+    reference["sha256"] = hashlib.sha256(content).hexdigest()
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("placeholder evidence" in failure for failure in result["failures"])
+
+
+def test_formal_evidence_may_describe_historical_local_or_blocked_results(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    reference = payload["uat_cases"][0]["evidence_refs"][0]
+    content = b"Historical PASS_LOCAL and BLOCKED_EXTERNAL results were superseded by this formal run."
+    (tmp_path / reference["path"]).write_bytes(content)
+    reference["sha256"] = hashlib.sha256(content).hexdigest()
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    assert validator.validate_acceptance_bundle(package)["status"] == "ACCEPTED"
+
+
+@pytest.mark.parametrize(
+    "content",
+    [
+        json.dumps(
+            {"previous_status": "BLOCKED_EXTERNAL", "status": "ENTERPRISE_PASS"}
+        ).encode(),
+        json.dumps(
+            {"historical_status": "PASS_LOCAL", "status": "ENTERPRISE_PASS"}
+        ).encode(),
+        json.dumps(
+            {"prior_status": "BLOCKED_EXTERNAL", "status": "ENTERPRISE_PASS"}
+        ).encode(),
+        json.dumps(
+            {"superseded_status": "PASS_LOCAL", "status": "ENTERPRISE_PASS"}
+        ).encode(),
+        json.dumps(
+            {"history": {"status": "BLOCKED_EXTERNAL"}, "status": "ENTERPRISE_PASS"}
+        ).encode(),
+        b"| previous_status | notes |\n|---|---|\n| BLOCKED_EXTERNAL | superseded |",
+        b"previous_status,notes\nPASS_LOCAL,superseded",
+        b"\xff\xfe\x00\x89binary-evidence",
+    ],
+)
+def test_structured_historical_or_binary_evidence_is_allowed(
+    tmp_path: Path, content: bytes
+) -> None:
+    package = _accepted_bundle(tmp_path)
+    _replace_first_case_evidence(package, content)
+    assert validator.validate_acceptance_bundle(package)["status"] == "ACCEPTED"
+
+
+@pytest.mark.parametrize(
+    "content",
+    [
+        json.dumps({"current_status": "BLOCKED_EXTERNAL"}).encode(),
+        json.dumps(
+            {"status": "ENTERPRISE_PASS", "measurement": "TBD_EXTERNAL"}
+        ).encode(),
+        json.dumps({"priority": "TBD_EXTERNAL"}).encode(),
+        json.dumps({"prioritization": "TBD_EXTERNAL"}).encode(),
+        json.dumps({"原因": "TBD_EXTERNAL"}, ensure_ascii=False).encode(),
+        json.dumps({"原值": "TBD_EXTERNAL"}, ensure_ascii=False).encode(),
+        b"| status | notes |\n|---|---|\n| BLOCKED_EXTERNAL | still open |",
+        b"| metric | measured_value |\n|---|---|\n| onboarding | TBD_EXTERNAL |",
+        b"| priority | TBD_EXTERNAL |",
+        b"state,notes\nPASS_LOCAL,still local",
+        b"metric,measured_value\nonboarding,TBD_EXTERNAL",
+        b"priority,TBD_EXTERNAL",
+    ],
+)
+def test_structured_current_state_evidence_is_rejected(
+    tmp_path: Path, content: bytes
+) -> None:
+    package = _accepted_bundle(tmp_path)
+    _replace_first_case_evidence(package, content)
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("placeholder evidence" in failure for failure in result["failures"])
+
+
+@pytest.mark.parametrize(
+    ("path", "invalid_value"),
+    [
+        (("source_cases",), {"case": "P2-WP13-UAT-021"}),
+        (("signoffs",), {"role": "product_owner"}),
+        (("preproduction_rehearsal", "rto_target_minutes"), "30"),
+        (("training_records", 0, "participants"), [None]),
+        (("signoffs", 0, "person_name"), 123),
+        (("metrics", 0, "numerator"), float("nan")),
+        (("metrics", 0, "value"), float("inf")),
+        (("preproduction_rehearsal", "rto_minutes"), float("nan")),
+        (("preproduction_rehearsal", "rpo_minutes"), float("inf")),
+    ],
+)
+def test_malformed_types_and_non_finite_numbers_fail_without_raising(
+    tmp_path: Path, path: tuple[object, ...], invalid_value: object
+) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    _set_nested(payload, path, invalid_value)
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert result["failures"]
+
+
+def test_self_approval_and_five_party_proxy_signing_are_rejected(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload["uat_cases"][0]["approved_by_person_id"] = payload["uat_cases"][0][
+        "executed_by_person_id"
+    ]
+    payload["signoffs"][1]["person_id"] = payload["signoffs"][0]["person_id"]
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("self-approval" in failure for failure in result["failures"])
+    assert any("proxy signing" in failure for failure in result["failures"])
+
+
+def test_open_p0_defect_blocks_acceptance(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload["defect_gate"]["p0_open"] = 1
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("p0_open" in failure for failure in result["failures"])
+
+
+def test_open_p1_with_dual_written_decision_is_accepted(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload["defect_gate"]["p1_open"] = 2
+    payload["defect_gate"]["open_p1_ids"] = ["P1-UAT-021-001", "P1-UAT-023-002"]
+    payload["defect_gate"]["p1_disposition"].update(
+        {
+            "status": "WRITTEN_DECISION_APPROVED",
+            "covered_p1_count": 2,
+            "covered_p1_ids": ["P1-UAT-023-002", "P1-UAT-021-001"],
+            "business_person_id": "p1-business-approver",
+            "technical_person_id": "p1-technical-approver",
+            "decided_at": "2026-08-04T14:30:00+08:00",
+        }
+    )
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    assert validator.validate_acceptance_bundle(package)["status"] == "ACCEPTED"
+
+
+def test_open_p1_without_written_decision_is_rejected(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload["defect_gate"]["p1_open"] = 1
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("WRITTEN_DECISION_APPROVED" in failure for failure in result["failures"])
+
+
+def test_open_p1_ids_must_be_unique_and_exactly_covered(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload["defect_gate"]["p1_open"] = 2
+    payload["defect_gate"]["open_p1_ids"] = ["P1-UAT-021-001", "P1-UAT-021-001"]
+    payload["defect_gate"]["p1_disposition"].update(
+        {
+            "status": "WRITTEN_DECISION_APPROVED",
+            "covered_p1_count": 2,
+            "covered_p1_ids": ["P1-UAT-021-001", "TBD_EXTERNAL"],
+            "business_person_id": "p1-business-approver",
+            "technical_person_id": "p1-technical-approver",
+            "decided_at": "2026-08-04T14:30:00+08:00",
+        }
+    )
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("unique P1 ids" in failure for failure in result["failures"])
+    assert any("invalid or placeholder P1 id" in failure for failure in result["failures"])
+
+
+@pytest.mark.parametrize(
+    ("field_path", "wrong_value", "message"),
+    [
+        (("release_artifact", "version"), "v0.3.0-phase2-hotfix", "release_artifact.version"),
+        (("execution_context", "executed_commit"), "5267d8bd8b30a58ed5fb6ec855ea579e7ce34d69", "executed_commit"),
+    ],
+)
+def test_release_version_and_tag_commit_are_exact(
+    tmp_path: Path, field_path: tuple[str, str], wrong_value: str, message: str
+) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload[field_path[0]][field_path[1]] = wrong_value
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any(message in failure for failure in result["failures"])
+
+
+def test_reversed_context_or_metric_window_is_rejected(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload["execution_context"]["started_at"] = "2026-08-05T19:00:00+08:00"
+    payload["metrics"][0]["window_start"] = "2026-08-03T19:00:00+08:00"
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("started_at must not be after completed_at" in failure for failure in result["failures"])
+    assert any("window_start must not be after window_end" in failure for failure in result["failures"])
+
+
+def test_case_and_rehearsal_environment_must_match_context(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload["uat_cases"][0]["environment_id"] = "preprod-other"
+    payload["preproduction_rehearsal"]["environment_id"] = "preprod-other"
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert sum("must match execution_context.environment_id" in failure for failure in result["failures"]) == 2
+
+
+def test_signoff_cannot_precede_acceptance_completion_events(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload["signoffs"][0]["signed_at"] = "2026-08-03T10:00:00+08:00"
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("must not precede completion event" in failure for failure in result["failures"])
+
+
+def test_incomplete_rehearsal_or_training_is_rejected(tmp_path: Path) -> None:
+    package = _accepted_bundle(tmp_path)
+    payload = json.loads(package.read_text())
+    payload["preproduction_rehearsal"]["steps"][2]["status"] = "BLOCKED_EXTERNAL"
+    payload["training_records"][0]["exercise_passed"] = False
+    package.write_text(json.dumps(payload), encoding="utf-8")
+    result = validator.validate_acceptance_bundle(package)
+    assert result["status"] == "FAILED"
+    assert any("rehearsal" in failure for failure in result["failures"])
+    assert any("training" in failure for failure in result["failures"])