validate_phase3_wp01_acceptance.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. #!/usr/bin/env python3
  2. """Fail-closed validator for the P3-WP01 enterprise acceptance package."""
  3. from __future__ import annotations
  4. import argparse
  5. import csv
  6. import hashlib
  7. import io
  8. import json
  9. import math
  10. import re
  11. from datetime import datetime
  12. from pathlib import Path
  13. from typing import Any
  14. BLOCKED = "ENGINEERING_READY_BLOCKED_EXTERNAL"
  15. COMPLETE = "ENTERPRISE_ACCEPTANCE_COMPLETE"
  16. EXPECTED_VERSION = "v0.3.0-phase2"
  17. EXPECTED_COMMIT = "95023e59bfb9f00be4ed61f7a8307fac56fbcee9"
  18. PLACEHOLDERS = {"", "TBD", "TBD_EXTERNAL", "UNKNOWN", "N/A", "PLACEHOLDER"}
  19. PURE_EVIDENCE_PLACEHOLDERS = PLACEHOLDERS | {"BLOCKED_EXTERNAL", "PASS_LOCAL"}
  20. UNFINISHED_STATE_VALUES = {
  21. "TBD_EXTERNAL",
  22. "BLOCKED_EXTERNAL",
  23. "PASS_LOCAL",
  24. "ENGINEERING_READY_BLOCKED_EXTERNAL",
  25. }
  26. INCOMPLETE_EVIDENCE_VALUES = PURE_EVIDENCE_PLACEHOLDERS | UNFINISHED_STATE_VALUES
  27. CASE_IDS = {f"P2-WP13-UAT-{number:03d}" for number in range(21, 25)}
  28. METRIC_TARGETS = {
  29. "core_object_onboarding_rate": 95,
  30. "incremental_change_accuracy": 99,
  31. "responsibility_coverage": 95,
  32. "quality_issue_closure_rate": 85,
  33. "incident_evidence_coverage": 100,
  34. "data_product_evidence_coverage": 100,
  35. }
  36. REHEARSAL_STEPS = {"install", "upgrade", "backup", "candidate_rollback", "rto_rpo"}
  37. TRAINING_IDS = {"user_training", "operations_training"}
  38. SIGNOFF_ROLES = {
  39. "product_owner",
  40. "business_owner",
  41. "technical_owner",
  42. "security_owner",
  43. "operations_owner",
  44. }
  45. SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
  46. P1_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
  47. CURRENT_STATUS_RE = re.compile(
  48. r"^\s*(?:[-*]\s*)?(?:\*\*)?(?:status|state|状态|当前状态)(?:\*\*)?\s*[::=]\s*"
  49. r"[`'\"*]*\s*(?:TBD_EXTERNAL|BLOCKED_EXTERNAL|PASS_LOCAL|ENGINEERING_READY_BLOCKED_EXTERNAL)"
  50. r"\s*[`'\"*]*(?:\s|[。;;,.]|$)",
  51. re.IGNORECASE | re.MULTILINE,
  52. )
  53. HISTORICAL_ENGLISH_TOKENS = {
  54. "history",
  55. "historical",
  56. "previous",
  57. "prior",
  58. "superseded",
  59. }
  60. HISTORICAL_CHINESE_KEYS = {"原状态", "先前状态", "前一状态"}
  61. def _missing(value: Any) -> bool:
  62. return value is None or (isinstance(value, str) and value.strip().upper() in PLACEHOLDERS)
  63. def _reject_json_constant(value: str) -> None:
  64. raise ValueError(f"non-finite JSON number is prohibited: {value}")
  65. def _is_finite_number(value: Any) -> bool:
  66. return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
  67. def _parse_iso_time(value: Any) -> datetime | None:
  68. if not isinstance(value, str) or _missing(value):
  69. return None
  70. try:
  71. parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
  72. except ValueError:
  73. return None
  74. return parsed if parsed.tzinfo is not None else None
  75. def _normalized_cell(value: str) -> str:
  76. return value.strip().strip("`'\"* ").upper()
  77. def _is_historical_key(value: Any) -> bool:
  78. if not isinstance(value, str):
  79. return False
  80. key = value.strip().lower()
  81. if key in HISTORICAL_ENGLISH_TOKENS:
  82. return True
  83. if any(
  84. key.startswith(f"{token}{separator}")
  85. for token in HISTORICAL_ENGLISH_TOKENS
  86. for separator in ("_", "-", " ")
  87. ):
  88. return True
  89. if any(key.endswith(f"{separator}history") for separator in ("_", "-", " ")):
  90. return True
  91. if key == "历史" or key.startswith("历史"):
  92. return True
  93. return key in HISTORICAL_CHINESE_KEYS or any(
  94. key.startswith(f"{token}{separator}")
  95. for token in HISTORICAL_CHINESE_KEYS
  96. for separator in ("_", "-", " ")
  97. )
  98. def _json_contains_incomplete_value(value: Any, *, historical_context: bool = False) -> bool:
  99. if isinstance(value, dict):
  100. for key, child in value.items():
  101. child_is_historical = historical_context or _is_historical_key(key)
  102. if _json_contains_incomplete_value(child, historical_context=child_is_historical):
  103. return True
  104. return False
  105. if isinstance(value, list):
  106. return any(
  107. _json_contains_incomplete_value(child, historical_context=historical_context)
  108. for child in value
  109. )
  110. return (
  111. not historical_context
  112. and isinstance(value, str)
  113. and value.strip().upper() in INCOMPLETE_EVIDENCE_VALUES
  114. )
  115. def _is_separator_row(cells: list[str]) -> bool:
  116. return bool(cells) and all(not cell or re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in cells)
  117. def _table_contains_current_placeholder(rows: list[list[str]]) -> bool:
  118. rows = [[cell.strip() for cell in row] for row in rows if any(cell.strip() for cell in row)]
  119. if not rows:
  120. return False
  121. header = rows[0]
  122. historical_columns = {
  123. index for index, cell in enumerate(header) if _is_historical_key(_normalized_cell(cell))
  124. }
  125. for cells in rows:
  126. if _is_separator_row(cells):
  127. continue
  128. if cells and _is_historical_key(_normalized_cell(cells[0])):
  129. continue
  130. for index, cell in enumerate(cells):
  131. if (
  132. index not in historical_columns
  133. and _normalized_cell(cell) in INCOMPLETE_EVIDENCE_VALUES
  134. ):
  135. return True
  136. return False
  137. def _is_placeholder_evidence(evidence_bytes: bytes) -> bool:
  138. """Reject structured current-state placeholders without guessing about binary evidence."""
  139. try:
  140. evidence_text = evidence_bytes.decode("utf-8")
  141. except UnicodeDecodeError:
  142. return False
  143. if evidence_text.strip().upper() in INCOMPLETE_EVIDENCE_VALUES:
  144. return True
  145. try:
  146. parsed_json = json.loads(evidence_text)
  147. except json.JSONDecodeError:
  148. parsed_json = None
  149. if parsed_json is not None and _json_contains_incomplete_value(parsed_json):
  150. return True
  151. if CURRENT_STATUS_RE.search(evidence_text):
  152. return True
  153. 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]
  154. if _table_contains_current_placeholder(markdown_rows):
  155. return True
  156. try:
  157. csv_rows = list(csv.reader(io.StringIO(evidence_text)))
  158. except csv.Error:
  159. csv_rows = []
  160. return any(len(row) > 1 for row in csv_rows) and _table_contains_current_placeholder(csv_rows)
  161. def _records_by_id(records: Any, key: str, expected: set[str], failures: list[str], label: str) -> dict[str, dict]:
  162. if not isinstance(records, list):
  163. failures.append(f"{label} must be a list")
  164. return {}
  165. mapped: dict[str, dict] = {}
  166. for record in records:
  167. if (
  168. not isinstance(record, dict)
  169. or not isinstance(record.get(key), str)
  170. or _missing(record.get(key))
  171. ):
  172. failures.append(f"{label} contains an invalid record")
  173. continue
  174. record_id = record[key]
  175. if record_id in mapped:
  176. failures.append(f"{label} contains duplicate {record_id}")
  177. mapped[record_id] = record
  178. if set(mapped) != expected:
  179. failures.append(f"{label} ids must be exactly {sorted(expected)}")
  180. return mapped
  181. def _validate_acceptance_bundle(package_path: Path, evidence_root: Path | None = None) -> dict[str, Any]:
  182. package_path = Path(package_path).resolve()
  183. failures: list[str] = []
  184. blockers: list[str] = []
  185. try:
  186. payload = json.loads(
  187. package_path.read_text(encoding="utf-8"), parse_constant=_reject_json_constant
  188. )
  189. except (OSError, json.JSONDecodeError, ValueError) as exc:
  190. return {"status": "FAILED", "declared_status": None, "blockers": [], "failures": [f"cannot read package: {exc}"]}
  191. if not isinstance(payload, dict):
  192. return {"status": "FAILED", "declared_status": None, "blockers": [], "failures": ["package root must be an object"]}
  193. declared = payload.get("declared_status")
  194. if not isinstance(declared, str) or declared not in {BLOCKED, COMPLETE}:
  195. failures.append(f"invalid declared_status: {declared!r}")
  196. enterprise_claim = declared == COMPLETE
  197. root = Path(evidence_root).resolve() if evidence_root else (
  198. package_path.parent.parent.resolve() if package_path.parent.name == "acceptance" else package_path.parent.resolve()
  199. )
  200. def pending_or_fail(label: str) -> None:
  201. message = f"{label} is missing or placeholder"
  202. (failures if enterprise_claim else blockers).append(message)
  203. def require(value: Any, label: str) -> bool:
  204. if _missing(value):
  205. pending_or_fail(label)
  206. return False
  207. return True
  208. def require_string(value: Any, label: str) -> bool:
  209. if value is None or (
  210. isinstance(value, str)
  211. and value.strip().upper() in INCOMPLETE_EVIDENCE_VALUES
  212. ):
  213. pending_or_fail(label)
  214. return False
  215. if not isinstance(value, str) or not value.strip():
  216. failures.append(f"{label} must be a non-empty, non-placeholder string")
  217. return False
  218. return True
  219. def object_field(container: dict[str, Any], key: str, label: str) -> dict[str, Any]:
  220. value = container.get(key)
  221. if not isinstance(value, dict):
  222. failures.append(f"{label} must be an object")
  223. return {}
  224. return value
  225. def require_time(value: Any, label: str) -> datetime | None:
  226. if not require_string(value, label):
  227. return None
  228. parsed = _parse_iso_time(value)
  229. if parsed is None:
  230. failures.append(f"{label} must be an ISO-8601 timestamp with timezone")
  231. return parsed
  232. def validate_refs(refs: Any, label: str) -> None:
  233. if not isinstance(refs, list):
  234. failures.append(f"{label}.evidence_refs must be a list")
  235. return
  236. if not refs:
  237. pending_or_fail(f"{label}.evidence_refs")
  238. return
  239. for index, ref in enumerate(refs):
  240. ref_label = f"{label}.evidence_refs[{index}]"
  241. if not isinstance(ref, dict):
  242. failures.append(f"{ref_label} must be an object")
  243. continue
  244. raw_path = ref.get("path")
  245. if not require_string(raw_path, f"{ref_label}.path"):
  246. continue
  247. relative = Path(raw_path)
  248. if relative.is_absolute() or ".." in relative.parts:
  249. failures.append(f"{ref_label} evidence path escapes evidence root")
  250. continue
  251. resolved = (root / relative).resolve()
  252. try:
  253. resolved.relative_to(root)
  254. except ValueError:
  255. failures.append(f"{ref_label} evidence path escapes evidence root")
  256. continue
  257. digest = ref.get("sha256")
  258. level = ref.get("evidence_level")
  259. if not resolved.is_file():
  260. if enterprise_claim or not _missing(digest):
  261. failures.append(f"{ref_label} missing evidence file: {raw_path}")
  262. else:
  263. blockers.append(f"{ref_label} external evidence file is not yet captured")
  264. if require_string(digest, f"{ref_label}.sha256") and not SHA256_RE.fullmatch(digest):
  265. failures.append(f"{ref_label}.sha256 must be a lowercase SHA-256 digest")
  266. elif isinstance(digest, str) and SHA256_RE.fullmatch(digest) and resolved.is_file():
  267. evidence_bytes = resolved.read_bytes()
  268. actual = hashlib.sha256(evidence_bytes).hexdigest()
  269. if actual != digest:
  270. failures.append(f"{ref_label} digest mismatch")
  271. if _is_placeholder_evidence(evidence_bytes):
  272. failures.append(f"{ref_label} placeholder evidence cannot satisfy enterprise acceptance")
  273. if require_string(level, f"{ref_label}.evidence_level") and level != "ENTERPRISE_FORMAL":
  274. failures.append(f"{ref_label} evidence level {level!r} is not ENTERPRISE_FORMAL")
  275. if payload.get("schema_version") != "1.0" or payload.get("work_package") != "P3-WP01":
  276. failures.append("schema_version/work_package contract mismatch")
  277. source_cases = payload.get("source_cases")
  278. if (
  279. not isinstance(source_cases, list)
  280. or any(not isinstance(item, str) or _missing(item) for item in source_cases)
  281. or set(source_cases) != CASE_IDS
  282. ):
  283. failures.append("source_cases must contain UAT-021 through UAT-024 exactly")
  284. context = object_field(payload, "execution_context", "execution_context")
  285. for field in ("enterprise_name", "environment_id"):
  286. require_string(context.get(field), f"execution_context.{field}")
  287. if require_string(context.get("environment_type"), "execution_context.environment_type") and context.get("environment_type") != "PREPRODUCTION":
  288. failures.append("execution_context.environment_type must be PREPRODUCTION")
  289. commit = context.get("executed_commit")
  290. if require_string(commit, "execution_context.executed_commit") and commit != EXPECTED_COMMIT:
  291. failures.append(f"execution_context.executed_commit must equal {EXPECTED_COMMIT}")
  292. context_started = require_time(context.get("started_at"), "execution_context.started_at")
  293. context_completed = require_time(context.get("completed_at"), "execution_context.completed_at")
  294. if context_started and context_completed and context_started > context_completed:
  295. failures.append("execution_context.started_at must not be after completed_at")
  296. completion_events: list[tuple[str, datetime]] = []
  297. def bind_time(value: Any, label: str, *, completion_event: bool = False) -> datetime | None:
  298. parsed = require_time(value, label)
  299. if parsed and context_started and parsed < context_started:
  300. failures.append(f"{label} must be within execution_context time range")
  301. if parsed and context_completed and parsed > context_completed:
  302. failures.append(f"{label} must be within execution_context time range")
  303. if parsed and completion_event:
  304. completion_events.append((label, parsed))
  305. return parsed
  306. source = object_field(payload, "source", "source")
  307. for field in ("source_id", "source_type", "network_zone"):
  308. require_string(source.get(field), f"source.{field}")
  309. if source.get("readonly") is None:
  310. pending_or_fail("source.readonly")
  311. elif source.get("readonly") is not True:
  312. failures.append("source.readonly must be true")
  313. if source.get("credentials_in_package") is not False:
  314. failures.append("source.credentials_in_package must be false")
  315. sample = object_field(payload, "sample_summary", "sample_summary")
  316. if require_string(sample.get("classification"), "sample_summary.classification") and sample.get("classification") != "DESENSITIZED_ENTERPRISE_SAMPLE":
  317. failures.append("sample_summary.classification must be DESENSITIZED_ENTERPRISE_SAMPLE")
  318. for field in ("object_count", "row_count"):
  319. value = sample.get(field)
  320. if not require(value, f"sample_summary.{field}"):
  321. continue
  322. if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
  323. failures.append(f"sample_summary.{field} must be a positive integer")
  324. sample_digest = sample.get("sha256")
  325. if require_string(sample_digest, "sample_summary.sha256") and not SHA256_RE.fullmatch(sample_digest):
  326. failures.append("sample_summary.sha256 must be a lowercase SHA-256 digest")
  327. if sample.get("raw_data_in_evidence") is not False:
  328. failures.append("sample_summary.raw_data_in_evidence must be false")
  329. artifact = object_field(payload, "release_artifact", "release_artifact")
  330. version = artifact.get("version")
  331. if require_string(version, "release_artifact.version") and version != EXPECTED_VERSION:
  332. failures.append(f"release_artifact.version must equal {EXPECTED_VERSION}")
  333. require_string(artifact.get("artifact_name"), "release_artifact.artifact_name")
  334. artifact_digest = artifact.get("sha256")
  335. if require_string(artifact_digest, "release_artifact.sha256") and not SHA256_RE.fullmatch(artifact_digest):
  336. failures.append("release_artifact.sha256 must be a lowercase SHA-256 digest")
  337. validate_refs(artifact.get("evidence_refs"), "release_artifact")
  338. cases = _records_by_id(payload.get("uat_cases"), "id", CASE_IDS, failures, "uat_cases")
  339. for case_id, case in cases.items():
  340. result = case.get("result")
  341. if result == "PASS_LOCAL":
  342. failures.append(f"{case_id} PASS_LOCAL cannot satisfy enterprise acceptance")
  343. result_valid = False
  344. else:
  345. result_valid = require_string(result, f"{case_id}.result")
  346. if result_valid and result != "ENTERPRISE_PASS":
  347. if enterprise_claim:
  348. failures.append(f"{case_id} must be ENTERPRISE_PASS")
  349. else:
  350. blockers.append(f"{case_id} awaits enterprise execution")
  351. for field in ("environment_id", "executed_by_person_id", "approved_by_person_id"):
  352. require_string(case.get(field), f"{case_id}.{field}")
  353. if isinstance(case.get("environment_id"), str) and isinstance(context.get("environment_id"), str) and case.get("environment_id") != context.get("environment_id"):
  354. failures.append(f"{case_id}.environment_id must match execution_context.environment_id")
  355. bind_time(case.get("executed_at"), f"{case_id}.executed_at", completion_event=True)
  356. if case.get("executed_by_person_id") and case.get("executed_by_person_id") == case.get("approved_by_person_id"):
  357. failures.append(f"{case_id} self-approval is prohibited")
  358. validate_refs(case.get("evidence_refs"), case_id)
  359. metrics = _records_by_id(payload.get("metrics"), "id", set(METRIC_TARGETS), failures, "metrics")
  360. for metric_id, metric in metrics.items():
  361. target = METRIC_TARGETS[metric_id]
  362. comparator_valid = require_string(metric.get("comparator"), f"{metric_id}.comparator")
  363. unit_valid = require_string(metric.get("unit"), f"{metric_id}.unit")
  364. target_value = metric.get("target")
  365. target_ready = require(target_value, f"{metric_id}.target")
  366. target_valid = target_ready and _is_finite_number(target_value)
  367. if target_ready and not target_valid:
  368. failures.append(f"{metric_id}.target must be a finite number")
  369. if not (comparator_valid and unit_valid and target_valid) or metric.get("comparator") != "GTE" or target_value != target or metric.get("unit") != "percent":
  370. failures.append(f"{metric_id} threshold contract must be GTE {target} percent")
  371. metric_status_valid = require_string(metric.get("status"), f"{metric_id}.status")
  372. if metric_status_valid and metric.get("status") != "ENTERPRISE_PASS":
  373. (failures if enterprise_claim else blockers).append(f"{metric_id} awaits enterprise measurement")
  374. numerator, denominator, value = metric.get("numerator"), metric.get("denominator"), metric.get("value")
  375. values_ready = True
  376. for item, field in ((numerator, "numerator"), (denominator, "denominator"), (value, "value")):
  377. if not require(item, f"{metric_id}.{field}"):
  378. values_ready = False
  379. elif not _is_finite_number(item):
  380. failures.append(f"{metric_id}.{field} must be a finite number")
  381. values_ready = False
  382. if values_ready:
  383. if numerator < 0 or denominator <= 0 or value < 0:
  384. failures.append(f"{metric_id} metric values are invalid")
  385. else:
  386. calculated = numerator / denominator * 100
  387. if abs(calculated - value) > 1e-6:
  388. failures.append(f"{metric_id} value does not match numerator/denominator")
  389. if value < target:
  390. failures.append(f"{metric_id} threshold {target}% is not met")
  391. window_start = bind_time(metric.get("window_start"), f"{metric_id}.window_start")
  392. window_end = bind_time(metric.get("window_end"), f"{metric_id}.window_end", completion_event=True)
  393. if window_start and window_end and window_start > window_end:
  394. failures.append(f"{metric_id}.window_start must not be after window_end")
  395. require_string(metric.get("approved_by_person_id"), f"{metric_id}.approved_by_person_id")
  396. validate_refs(metric.get("evidence_refs"), metric_id)
  397. rehearsal = object_field(payload, "preproduction_rehearsal", "preproduction_rehearsal")
  398. rehearsal_status_valid = require_string(rehearsal.get("status"), "preproduction_rehearsal.status")
  399. if rehearsal_status_valid and rehearsal.get("status") != "ENTERPRISE_PASS":
  400. (failures if enterprise_claim else blockers).append("preproduction rehearsal is incomplete")
  401. for field in ("environment_id", "executed_by_person_id", "approved_by_person_id"):
  402. require_string(rehearsal.get(field), f"preproduction_rehearsal.{field}")
  403. if isinstance(rehearsal.get("environment_id"), str) and isinstance(context.get("environment_id"), str) and rehearsal.get("environment_id") != context.get("environment_id"):
  404. failures.append("preproduction_rehearsal.environment_id must match execution_context.environment_id")
  405. bind_time(rehearsal.get("executed_at"), "preproduction_rehearsal.executed_at", completion_event=True)
  406. if rehearsal.get("executed_by_person_id") and rehearsal.get("executed_by_person_id") == rehearsal.get("approved_by_person_id"):
  407. failures.append("preproduction rehearsal self-approval is prohibited")
  408. steps = _records_by_id(rehearsal.get("steps"), "id", REHEARSAL_STEPS, failures, "preproduction rehearsal steps")
  409. for step_id, step in steps.items():
  410. step_status_valid = require_string(step.get("status"), f"preproduction rehearsal step {step_id}.status")
  411. if step_status_valid and step.get("status") != "ENTERPRISE_PASS":
  412. (failures if enterprise_claim else blockers).append(f"preproduction rehearsal step {step_id} is incomplete")
  413. for observed, target_field in (("rto_minutes", "rto_target_minutes"), ("rpo_minutes", "rpo_target_minutes")):
  414. target_value = rehearsal.get(target_field)
  415. target_valid = require(target_value, f"preproduction_rehearsal.{target_field}")
  416. if target_valid and (not _is_finite_number(target_value) or target_value <= 0):
  417. failures.append(f"preproduction_rehearsal.{target_field} must be a positive finite number")
  418. target_valid = False
  419. value = rehearsal.get(observed)
  420. value_valid = require(value, f"preproduction_rehearsal.{observed}")
  421. if value_valid and (not _is_finite_number(value) or value < 0):
  422. failures.append(f"preproduction_rehearsal.{observed} must be a non-negative finite number")
  423. value_valid = False
  424. if target_valid and value_valid and value > target_value:
  425. failures.append(f"preproduction rehearsal {observed} exceeds its target")
  426. validate_refs(rehearsal.get("evidence_refs"), "preproduction_rehearsal")
  427. trainings = _records_by_id(payload.get("training_records"), "id", TRAINING_IDS, failures, "training_records")
  428. for training_id, record in trainings.items():
  429. training_status_valid = require_string(record.get("status"), f"training {training_id}.status")
  430. if training_status_valid and record.get("status") != "ENTERPRISE_PASS":
  431. (failures if enterprise_claim else blockers).append(f"training {training_id} is incomplete")
  432. participants = record.get("participants")
  433. if not isinstance(participants, list):
  434. failures.append(f"training {training_id}.participants must be a list")
  435. elif not participants:
  436. pending_or_fail(f"training {training_id}.participants")
  437. else:
  438. for index, participant in enumerate(participants):
  439. require_string(participant, f"training {training_id}.participants[{index}]")
  440. require_string(record.get("trainer_person_id"), f"training {training_id}.trainer_person_id")
  441. bind_time(record.get("completed_at"), f"training {training_id}.completed_at", completion_event=True)
  442. if record.get("exercise_passed") is not True:
  443. (failures if enterprise_claim else blockers).append(f"training {training_id} exercise is incomplete")
  444. if record.get("retraining_required") is None:
  445. pending_or_fail(f"training {training_id}.retraining_required")
  446. elif not isinstance(record.get("retraining_required"), bool):
  447. failures.append(f"training {training_id}.retraining_required must be boolean")
  448. if record.get("retraining_completed") is None:
  449. pending_or_fail(f"training {training_id}.retraining_completed")
  450. elif not isinstance(record.get("retraining_completed"), bool):
  451. failures.append(f"training {training_id}.retraining_completed must be boolean")
  452. if record.get("retraining_required") is True and record.get("retraining_completed") is not True:
  453. failures.append(f"training {training_id} required retraining is incomplete")
  454. validate_refs(record.get("evidence_refs"), f"training {training_id}")
  455. defects = object_field(payload, "defect_gate", "defect_gate")
  456. defect_status_valid = require_string(defects.get("status"), "defect_gate.status")
  457. if defect_status_valid and defects.get("status") != "ENTERPRISE_PASS":
  458. (failures if enterprise_claim else blockers).append("defect gate awaits enterprise review")
  459. p0_open = defects.get("p0_open")
  460. if require(p0_open, "defect_gate.p0_open") and (
  461. not isinstance(p0_open, int) or isinstance(p0_open, bool) or p0_open != 0
  462. ):
  463. failures.append("defect_gate.p0_open must be 0")
  464. p1_open = defects.get("p1_open")
  465. p1_ready = require(p1_open, "defect_gate.p1_open")
  466. if p1_ready and (not isinstance(p1_open, int) or isinstance(p1_open, bool) or p1_open < 0):
  467. failures.append("defect_gate.p1_open must be a non-negative integer")
  468. p1_ready = False
  469. open_p1_ids = defects.get("open_p1_ids")
  470. def valid_nonnegative_count(value: Any, label: str) -> bool:
  471. if not isinstance(value, int) or isinstance(value, bool) or value < 0:
  472. failures.append(f"{label} must be a non-negative integer")
  473. return False
  474. return True
  475. def valid_p1_ids(value: Any, label: str) -> bool:
  476. if not isinstance(value, list):
  477. failures.append(f"{label} must be a list")
  478. return False
  479. if any(not isinstance(item, str) or _missing(item) or not P1_ID_RE.fullmatch(item) for item in value):
  480. failures.append(f"{label} contains an invalid or placeholder P1 id")
  481. return False
  482. if len(value) != len(set(value)):
  483. failures.append(f"{label} must contain unique P1 ids")
  484. return False
  485. return True
  486. disposition = object_field(defects, "p1_disposition", "defect_gate.p1_disposition")
  487. disposition_status = disposition.get("status")
  488. if not require_string(disposition_status, "defect_gate.p1_disposition.status"):
  489. pass
  490. elif p1_ready and p1_open == 0:
  491. if open_p1_ids != []:
  492. failures.append("defect_gate.open_p1_ids must be empty when p1_open is 0")
  493. if disposition_status != "NOT_REQUIRED":
  494. failures.append("defect_gate.p1_disposition.status must be NOT_REQUIRED when p1_open is 0")
  495. covered_count = disposition.get("covered_p1_count")
  496. if not valid_nonnegative_count(covered_count, "defect_gate.p1_disposition.covered_p1_count") or covered_count != 0:
  497. failures.append("defect_gate.p1_disposition.covered_p1_count must be 0 when p1_open is 0")
  498. if disposition.get("covered_p1_ids") != []:
  499. failures.append("defect_gate.p1_disposition.covered_p1_ids must be empty when p1_open is 0")
  500. elif p1_ready and p1_open > 0:
  501. open_ids_valid = valid_p1_ids(open_p1_ids, "defect_gate.open_p1_ids")
  502. if open_ids_valid and len(open_p1_ids) != p1_open:
  503. failures.append("defect_gate.open_p1_ids count must equal p1_open")
  504. if disposition_status != "WRITTEN_DECISION_APPROVED":
  505. failures.append("open P1 defects require WRITTEN_DECISION_APPROVED disposition")
  506. covered_count = disposition.get("covered_p1_count")
  507. if not valid_nonnegative_count(covered_count, "defect_gate.p1_disposition.covered_p1_count") or covered_count != p1_open:
  508. failures.append("P1 written decision must cover every open P1 defect")
  509. covered_p1_ids = disposition.get("covered_p1_ids")
  510. covered_ids_valid = valid_p1_ids(covered_p1_ids, "defect_gate.p1_disposition.covered_p1_ids")
  511. if open_ids_valid and covered_ids_valid and set(covered_p1_ids) != set(open_p1_ids):
  512. failures.append("P1 written decision covered_p1_ids must exactly match open_p1_ids")
  513. disposition_business = disposition.get("business_person_id")
  514. disposition_technical = disposition.get("technical_person_id")
  515. require_string(disposition_business, "defect_gate.p1_disposition.business_person_id")
  516. require_string(disposition_technical, "defect_gate.p1_disposition.technical_person_id")
  517. if disposition_business and disposition_business == disposition_technical:
  518. failures.append("P1 written decision business and technical approvers must be distinct")
  519. bind_time(disposition.get("decided_at"), "defect_gate.p1_disposition.decided_at", completion_event=True)
  520. validate_refs(disposition.get("evidence_refs"), "defect_gate.p1_disposition")
  521. business_reviewer = defects.get("reviewed_by_business_person_id")
  522. technical_reviewer = defects.get("reviewed_by_technical_person_id")
  523. require_string(business_reviewer, "defect_gate.reviewed_by_business_person_id")
  524. require_string(technical_reviewer, "defect_gate.reviewed_by_technical_person_id")
  525. if business_reviewer and business_reviewer == technical_reviewer:
  526. failures.append("defect gate business and technical reviewers must be distinct")
  527. bind_time(defects.get("reviewed_at"), "defect_gate.reviewed_at", completion_event=True)
  528. validate_refs(defects.get("evidence_refs"), "defect_gate")
  529. signoffs = _records_by_id(payload.get("signoffs"), "role", SIGNOFF_ROLES, failures, "signoffs")
  530. signer_ids: list[str] = []
  531. signoff_times: list[tuple[str, datetime]] = []
  532. for role, signoff in signoffs.items():
  533. signoff_status_valid = require_string(signoff.get("status"), f"{role}.status")
  534. if signoff_status_valid and signoff.get("status") != "SIGNED":
  535. (failures if enterprise_claim else blockers).append(f"{role} signoff is not signed")
  536. if require_string(signoff.get("person_id"), f"{role}.person_id"):
  537. signer_ids.append(signoff["person_id"])
  538. require_string(signoff.get("person_name"), f"{role}.person_name")
  539. signed_at = bind_time(signoff.get("signed_at"), f"{role}.signed_at")
  540. if signed_at:
  541. signoff_times.append((role, signed_at))
  542. validate_refs(signoff.get("evidence_refs"), role)
  543. if len(signer_ids) != len(set(signer_ids)):
  544. failures.append("five-party proxy signing is prohibited; each role needs a distinct person_id")
  545. if completion_events:
  546. latest_label, latest_completion = max(completion_events, key=lambda item: item[1])
  547. for role, signed_at in signoff_times:
  548. if signed_at < latest_completion:
  549. failures.append(f"{role}.signed_at must not precede completion event {latest_label}")
  550. failures = list(dict.fromkeys(failures))
  551. blockers = list(dict.fromkeys(blockers))
  552. if failures:
  553. status = "FAILED"
  554. elif blockers:
  555. status = "FAILED" if enterprise_claim else "BLOCKED"
  556. if enterprise_claim:
  557. failures = [f"acceptance claim is incomplete: {blocker}" for blocker in blockers]
  558. blockers = []
  559. elif enterprise_claim:
  560. status = "ACCEPTED"
  561. else:
  562. status = "FAILED"
  563. failures = ["blocked package contains no external blockers; declared status is inconsistent"]
  564. return {"status": status, "declared_status": declared, "blockers": blockers, "failures": failures}
  565. def validate_acceptance_bundle(package_path: Path, evidence_root: Path | None = None) -> dict[str, Any]:
  566. """Validate a bundle and convert expected malformed-input errors into FAILED."""
  567. try:
  568. return _validate_acceptance_bundle(package_path, evidence_root)
  569. except (KeyError, OverflowError, TypeError, ValueError) as exc:
  570. return {
  571. "status": "FAILED",
  572. "declared_status": None,
  573. "blockers": [],
  574. "failures": [f"malformed acceptance package: {type(exc).__name__}: {exc}"],
  575. }
  576. def main() -> int:
  577. parser = argparse.ArgumentParser(description=__doc__)
  578. parser.add_argument("package", type=Path, help="enterprise acceptance JSON package")
  579. parser.add_argument("--evidence-root", type=Path)
  580. args = parser.parse_args()
  581. result = validate_acceptance_bundle(args.package, args.evidence_root)
  582. print(json.dumps(result, ensure_ascii=False, indent=2))
  583. return {"ACCEPTED": 0, "FAILED": 2, "BLOCKED": 3}[result["status"]]
  584. if __name__ == "__main__":
  585. raise SystemExit(main())