domain_replication.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. """Generic, evidence-bound acceptance for copying a governance domain.
  2. The evaluator consumes a self-contained implementation package. It validates
  3. controlled snapshot/delta ingestion and canonical receipts from existing
  4. platform modules; it does not execute domain-specific business logic or store
  5. source rows in the report.
  6. """
  7. from __future__ import annotations
  8. import hashlib
  9. import json
  10. import re
  11. from copy import deepcopy
  12. from typing import Any
  13. REQUIRED_STAGES = (
  14. "template_initialization",
  15. "incremental_ingestion",
  16. "catalog",
  17. "semantics",
  18. "responsibility",
  19. "quality",
  20. "remediation",
  21. "observability",
  22. "data_product",
  23. "agent",
  24. )
  25. STAGE_SUBSYSTEMS = {
  26. "template_initialization": "domain_templates",
  27. "incremental_ingestion": "active_metadata",
  28. "catalog": "active_metadata",
  29. "semantics": "semantic_governance",
  30. "responsibility": "unified_responsibilities",
  31. "quality": "quality_operations",
  32. "remediation": "unified_work_center",
  33. "observability": "data_observability",
  34. "data_product": "product_governance",
  35. "agent": "agent_governance",
  36. }
  37. _CODE = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
  38. _SECRET = re.compile(
  39. r"(?:password|passwd|secret|token|credential|authorization|api[_-]?key)",
  40. re.IGNORECASE,
  41. )
  42. _DEVICE_PATH_MARKERS = (
  43. "app/core/data_research/device_",
  44. "app/api/data_development/device_",
  45. "frontend/src/views/dataResearch/device",
  46. )
  47. _REQUIRED_ENTERPRISE_BINDINGS = {
  48. "data_steward",
  49. "domain_owner",
  50. "readonly_source",
  51. "uat_users",
  52. }
  53. class DomainReplicationError(ValueError):
  54. """Raised when an implementation package cannot prove a replication gate."""
  55. def _canonical(value: Any) -> bytes:
  56. return json.dumps(
  57. value,
  58. ensure_ascii=False,
  59. sort_keys=True,
  60. separators=(",", ":"),
  61. ).encode("utf-8")
  62. def _digest(value: Any) -> str:
  63. return hashlib.sha256(_canonical(value)).hexdigest()
  64. def _mapping(value: Any, field: str) -> dict[str, Any]:
  65. if not isinstance(value, dict):
  66. raise DomainReplicationError(f"{field} must be an object")
  67. return value
  68. def _bounded_text(value: Any, field: str, limit: int = 256) -> str:
  69. rendered = str(value or "").strip()
  70. if not rendered or len(rendered) > limit:
  71. raise DomainReplicationError(f"{field} must be non-empty and <= {limit}")
  72. return rendered
  73. def _reject_secrets(value: Any, path: str = "$") -> None:
  74. if isinstance(value, dict):
  75. for key, item in value.items():
  76. if _SECRET.search(str(key)):
  77. raise DomainReplicationError(
  78. f"secret material is not allowed at {path}.{key}"
  79. )
  80. _reject_secrets(item, f"{path}.{key}")
  81. elif isinstance(value, list):
  82. for index, item in enumerate(value):
  83. _reject_secrets(item, f"{path}[{index}]")
  84. def _row_key(row: dict[str, str], fields: list[str]) -> tuple[str, ...]:
  85. values = tuple(str(row.get(field, "")).strip() for field in fields)
  86. if any(not value for value in values):
  87. raise DomainReplicationError("source primary key values must be non-empty")
  88. return values
  89. def _normalized_row(
  90. row: dict[str, str], *, operation_field: str, cursor_field: str
  91. ) -> dict[str, str]:
  92. return {
  93. str(key): str(value or "").strip()
  94. for key, value in sorted(row.items())
  95. if key not in {operation_field, cursor_field}
  96. }
  97. def _validate_source_fields(rows: list[dict[str, str]]) -> None:
  98. for row in rows:
  99. if not isinstance(row, dict):
  100. raise DomainReplicationError("source rows must be objects")
  101. for field in row:
  102. if _SECRET.search(str(field)):
  103. raise DomainReplicationError(
  104. f"secret-like source field is not allowed: {field}"
  105. )
  106. def collect_incremental_rows(
  107. snapshot_rows: list[dict[str, str]],
  108. delta_rows: list[dict[str, str]],
  109. source: dict[str, Any],
  110. ) -> dict[str, Any]:
  111. """Apply a controlled snapshot and monotonic delta without exposing rows."""
  112. source = _mapping(source, "source")
  113. if source.get("kind") != "controlled_csv":
  114. raise DomainReplicationError("source.kind must be controlled_csv")
  115. if source.get("classification") != "desensitized":
  116. raise DomainReplicationError("source.classification must be desensitized")
  117. primary_key = source.get("primary_key")
  118. if not isinstance(primary_key, list) or not primary_key:
  119. raise DomainReplicationError("source.primary_key must be a non-empty list")
  120. primary_key = [_bounded_text(item, "source.primary_key") for item in primary_key]
  121. operation_field = _bounded_text(
  122. source.get("operation_field", "_operation"), "source.operation_field"
  123. )
  124. cursor_field = _bounded_text(
  125. source.get("cursor_field", "_cursor"), "source.cursor_field"
  126. )
  127. required_columns = source.get("required_columns")
  128. if not isinstance(required_columns, list) or not required_columns:
  129. raise DomainReplicationError("source.required_columns must be a non-empty list")
  130. required_columns = {
  131. _bounded_text(item, "source.required_columns") for item in required_columns
  132. }
  133. cursor_before = int(source.get("snapshot_cursor", -1))
  134. if cursor_before < 0:
  135. raise DomainReplicationError("source.snapshot_cursor must be non-negative")
  136. if not snapshot_rows:
  137. raise DomainReplicationError("controlled snapshot must contain rows")
  138. if not delta_rows:
  139. raise DomainReplicationError("controlled delta must contain rows")
  140. _validate_source_fields(snapshot_rows)
  141. _validate_source_fields(delta_rows)
  142. state: dict[tuple[str, ...], dict[str, str]] = {}
  143. for row in snapshot_rows:
  144. if not required_columns.issubset(row):
  145. missing = sorted(required_columns - set(row))
  146. raise DomainReplicationError(f"snapshot is missing required columns: {missing}")
  147. key = _row_key(row, primary_key)
  148. if key in state:
  149. raise DomainReplicationError("snapshot primary keys must be unique")
  150. state[key] = _normalized_row(
  151. row, operation_field=operation_field, cursor_field=cursor_field
  152. )
  153. cursors: list[int] = []
  154. for row in delta_rows:
  155. if cursor_field not in row or operation_field not in row:
  156. raise DomainReplicationError("delta cursor and operation fields are required")
  157. try:
  158. cursor = int(row[cursor_field])
  159. except (TypeError, ValueError) as exc:
  160. raise DomainReplicationError("delta cursor must be an integer") from exc
  161. if cursor <= cursor_before or (cursors and cursor <= cursors[-1]):
  162. raise DomainReplicationError("delta cursors must strictly increase")
  163. cursors.append(cursor)
  164. def apply_delta() -> dict[str, int]:
  165. result = {"inserted": 0, "updated": 0, "deleted": 0}
  166. for row in delta_rows:
  167. operation = str(row[operation_field]).strip().lower()
  168. if operation not in {"upsert", "delete"}:
  169. raise DomainReplicationError(f"unsupported delta operation: {operation}")
  170. key = _row_key(row, primary_key)
  171. if operation == "delete":
  172. if key in state:
  173. del state[key]
  174. result["deleted"] += 1
  175. continue
  176. if not required_columns.issubset(row):
  177. missing = sorted(required_columns - set(row))
  178. raise DomainReplicationError(f"delta is missing required columns: {missing}")
  179. normalized = _normalized_row(
  180. row, operation_field=operation_field, cursor_field=cursor_field
  181. )
  182. if key not in state:
  183. state[key] = normalized
  184. result["inserted"] += 1
  185. elif state[key] != normalized:
  186. state[key] = normalized
  187. result["updated"] += 1
  188. return result
  189. first = apply_delta()
  190. first_state = deepcopy(state)
  191. replay = apply_delta()
  192. if state != first_state:
  193. raise DomainReplicationError("delta replay changed final source state")
  194. replay_changes = sum(replay.values())
  195. final_rows = [state[key] for key in sorted(state)]
  196. return {
  197. "source_kind": "controlled_csv",
  198. "classification": "desensitized",
  199. "snapshot_rows": len(snapshot_rows),
  200. "delta_rows": len(delta_rows),
  201. **first,
  202. "final_rows": len(state),
  203. "replay_changes": replay_changes,
  204. "cursor_before": cursor_before,
  205. "cursor_after": cursors[-1],
  206. "snapshot_digest": _digest(
  207. sorted(
  208. (
  209. _normalized_row(
  210. row,
  211. operation_field=operation_field,
  212. cursor_field=cursor_field,
  213. )
  214. for row in snapshot_rows
  215. ),
  216. key=_canonical,
  217. )
  218. ),
  219. "delta_digest": _digest(
  220. [
  221. {
  222. **_normalized_row(
  223. row,
  224. operation_field=operation_field,
  225. cursor_field=cursor_field,
  226. ),
  227. operation_field: row[operation_field],
  228. cursor_field: int(row[cursor_field]),
  229. }
  230. for row in delta_rows
  231. ]
  232. ),
  233. "final_digest": _digest(final_rows),
  234. }
  235. def _positive(metrics: dict[str, Any], field: str, minimum: float = 1) -> float:
  236. try:
  237. value = float(metrics.get(field, 0))
  238. except (TypeError, ValueError) as exc:
  239. raise DomainReplicationError(f"{field} must be numeric") from exc
  240. if value < minimum:
  241. raise DomainReplicationError(f"{field} must be >= {minimum:g}")
  242. return value
  243. def _validate_stage_metrics(
  244. stage: str,
  245. metrics: dict[str, Any],
  246. *,
  247. template: dict[str, Any],
  248. incremental: dict[str, Any],
  249. ) -> None:
  250. if stage == "template_initialization":
  251. expected = {
  252. "object_type_count": len(template.get("object_types", [])),
  253. "rule_count": len(template.get("rules", [])),
  254. "role_count": len(template.get("responsibility_roles", [])),
  255. "metric_count": len(template.get("metrics", [])),
  256. }
  257. if any(int(metrics.get(key, -1)) != value for key, value in expected.items()):
  258. raise DomainReplicationError("template initialization counts do not match template")
  259. _positive(metrics, "template_version")
  260. elif stage == "incremental_ingestion":
  261. for key in (
  262. "snapshot_rows",
  263. "delta_rows",
  264. "inserted",
  265. "updated",
  266. "deleted",
  267. "final_rows",
  268. "replay_changes",
  269. "cursor_before",
  270. "cursor_after",
  271. ):
  272. if int(metrics.get(key, -1)) != int(incremental[key]):
  273. raise DomainReplicationError(
  274. f"incremental ingestion evidence does not match collected {key}"
  275. )
  276. elif stage == "catalog":
  277. _positive(metrics, "asset_count", 3)
  278. _positive(metrics, "incremental_change_count")
  279. if int(metrics.get("cursor_after", -1)) != incremental["cursor_after"]:
  280. raise DomainReplicationError("catalog cursor does not match ingestion cursor")
  281. elif stage == "semantics":
  282. for field in (
  283. "published_term_count",
  284. "published_code_set_count",
  285. "published_metric_count",
  286. "mapped_field_count",
  287. ):
  288. _positive(metrics, field)
  289. elif stage == "responsibility":
  290. if float(metrics.get("coverage_percent", 0)) != 100:
  291. raise DomainReplicationError("responsibility coverage must be 100 percent")
  292. _positive(metrics, "bound_role_count", 3)
  293. elif stage == "quality":
  294. _positive(metrics, "published_rule_count", 5)
  295. _positive(metrics, "initial_finding_count")
  296. target = float(metrics.get("target_score", 0))
  297. final = float(metrics.get("final_score", 0))
  298. if target <= 0 or final < target:
  299. raise DomainReplicationError("quality final score must meet target score")
  300. elif stage == "remediation":
  301. expected = {
  302. "issue_status": "closed",
  303. "task_status": "completed",
  304. "independent_closer": True,
  305. }
  306. if metrics != expected:
  307. raise DomainReplicationError("remediation issue and task must be independently closed")
  308. elif stage == "observability":
  309. _positive(metrics, "slo_count")
  310. if metrics.get("incident_status") != "closed" or metrics.get("recovered") is not True:
  311. raise DomainReplicationError("observability incident must recover and close")
  312. elif stage == "data_product":
  313. if metrics.get("contract_status") != "active":
  314. raise DomainReplicationError("data product contract must be active")
  315. if metrics.get("certificate_status") != "issued":
  316. raise DomainReplicationError("data product certificate must be issued")
  317. refs = metrics.get("certificate_evidence_refs")
  318. if not isinstance(refs, list) or len(refs) < 4:
  319. raise DomainReplicationError("certificate evidence must bind quality, lineage, rule and workflow")
  320. required = ("quality://", "lineage://", "rule://", "workflow://")
  321. if not all(any(str(ref).startswith(prefix) for ref in refs) for prefix in required):
  322. raise DomainReplicationError("certificate evidence is missing canonical references")
  323. elif stage == "agent":
  324. if metrics.get("autonomy_level") not in {"read_only", "suggestion"}:
  325. raise DomainReplicationError("agent autonomy must be read_only or suggestion")
  326. if metrics.get("decision") != "authorized":
  327. raise DomainReplicationError("agent action must be authorized")
  328. if metrics.get("automatic_execution_allowed") is not False:
  329. raise DomainReplicationError("agent automatic execution must remain disabled")
  330. _positive(metrics, "citation_count")
  331. if metrics.get("cross_domain_denied") is not True:
  332. raise DomainReplicationError("agent cross-domain request must be denied")
  333. def _validate_manifest(manifest: dict[str, Any], template: dict[str, Any]) -> tuple[str, str]:
  334. manifest = _mapping(manifest, "manifest")
  335. _reject_secrets(manifest)
  336. if int(manifest.get("schema_version", 0)) != 1:
  337. raise DomainReplicationError("manifest.schema_version must be 1")
  338. package_code = _bounded_text(manifest.get("package_code"), "package_code", 80)
  339. domain = _mapping(manifest.get("domain"), "domain")
  340. domain_code = _bounded_text(domain.get("code"), "domain.code", 64)
  341. if not _CODE.fullmatch(domain_code):
  342. raise DomainReplicationError("domain.code must be a stable lowercase code")
  343. if template.get("template_code") != domain_code:
  344. raise DomainReplicationError("domain template code does not match package domain")
  345. if len(template.get("object_types", [])) < 3:
  346. raise DomainReplicationError("domain template must contain at least three object types")
  347. if len(template.get("rules", [])) < 5:
  348. raise DomainReplicationError("domain template must contain at least five rules")
  349. assessment = _mapping(
  350. manifest.get("core_change_assessment"), "core_change_assessment"
  351. )
  352. device_changes = assessment.get("device_specific_changes")
  353. if not isinstance(device_changes, list):
  354. raise DomainReplicationError("device_specific_changes must be a list")
  355. if device_changes:
  356. raise DomainReplicationError("device-specific core changes must remain zero")
  357. generic_extensions = assessment.get("generic_extensions")
  358. if not isinstance(generic_extensions, list) or not generic_extensions:
  359. raise DomainReplicationError("generic extension points must be documented")
  360. if any(
  361. str(path).startswith(marker)
  362. for path in generic_extensions
  363. for marker in _DEVICE_PATH_MARKERS
  364. ):
  365. raise DomainReplicationError("generic extensions cannot target device-specific paths")
  366. reuse = _mapping(manifest.get("third_domain_reuse"), "third_domain_reuse")
  367. if reuse.get("reusable") is not True:
  368. raise DomainReplicationError("third-domain reuse must be explicitly enabled")
  369. placeholders = reuse.get("required_replacements")
  370. if not isinstance(placeholders, list) or len(placeholders) < 4:
  371. raise DomainReplicationError("third-domain replacement checklist is incomplete")
  372. return package_code, domain_code
  373. def evaluate_replication_package(
  374. manifest: dict[str, Any],
  375. evidence: dict[str, Any],
  376. *,
  377. template: dict[str, Any],
  378. snapshot_rows: list[dict[str, str]],
  379. delta_rows: list[dict[str, str]],
  380. ) -> dict[str, Any]:
  381. """Evaluate one domain package and return a deterministic, row-free report."""
  382. package_code, domain_code = _validate_manifest(manifest, template)
  383. incremental = collect_incremental_rows(
  384. snapshot_rows,
  385. delta_rows,
  386. _mapping(manifest.get("source"), "source"),
  387. )
  388. evidence = _mapping(evidence, "evidence")
  389. _reject_secrets(evidence)
  390. if evidence.get("package_code") != package_code:
  391. raise DomainReplicationError("evidence package code does not match manifest")
  392. receipts = evidence.get("receipts")
  393. if not isinstance(receipts, list):
  394. raise DomainReplicationError("evidence.receipts must be a list")
  395. by_stage: dict[str, dict[str, Any]] = {}
  396. for receipt in receipts:
  397. receipt = _mapping(receipt, "receipt")
  398. stage = _bounded_text(receipt.get("stage"), "receipt.stage", 80)
  399. if stage in by_stage:
  400. raise DomainReplicationError(f"duplicate evidence stage: {stage}")
  401. by_stage[stage] = receipt
  402. missing = sorted(set(REQUIRED_STAGES) - set(by_stage))
  403. extra = sorted(set(by_stage) - set(REQUIRED_STAGES))
  404. if missing:
  405. raise DomainReplicationError(f"missing stages: {','.join(missing)}")
  406. if extra:
  407. raise DomainReplicationError(f"unsupported stages: {','.join(extra)}")
  408. stage_reports: dict[str, dict[str, Any]] = {}
  409. for stage in REQUIRED_STAGES:
  410. receipt = by_stage[stage]
  411. if receipt.get("domain_code") != domain_code:
  412. raise DomainReplicationError(f"{stage} evidence has the wrong domain")
  413. if receipt.get("status") != "passed":
  414. raise DomainReplicationError(f"{stage} evidence status must be passed")
  415. if receipt.get("subsystem") != STAGE_SUBSYSTEMS[stage]:
  416. raise DomainReplicationError(f"{stage} evidence subsystem is invalid")
  417. api_refs = receipt.get("api_refs")
  418. if not isinstance(api_refs, list) or not api_refs:
  419. raise DomainReplicationError(f"{stage} api_refs are required")
  420. if any("/device-" in str(ref) for ref in api_refs):
  421. raise DomainReplicationError("device-specific API evidence is not allowed")
  422. evidence_refs = receipt.get("evidence_refs")
  423. if not isinstance(evidence_refs, list) or not evidence_refs:
  424. raise DomainReplicationError(f"{stage} evidence_refs are required")
  425. metrics = _mapping(receipt.get("metrics"), f"{stage}.metrics")
  426. _validate_stage_metrics(
  427. stage,
  428. metrics,
  429. template=template,
  430. incremental=incremental,
  431. )
  432. stage_reports[stage] = {
  433. "status": "passed",
  434. "subsystem": STAGE_SUBSYSTEMS[stage],
  435. "api_refs": sorted(str(ref) for ref in api_refs),
  436. "evidence_refs": sorted(str(ref) for ref in evidence_refs),
  437. "metrics": deepcopy(metrics),
  438. }
  439. bindings = manifest.get("enterprise_bindings")
  440. if not isinstance(bindings, list) or not bindings:
  441. raise DomainReplicationError("enterprise binding checklist is required")
  442. binding_statuses: dict[str, str] = {}
  443. for item in bindings:
  444. item = _mapping(item, "enterprise_bindings.item")
  445. kind = _bounded_text(item.get("kind"), "enterprise_bindings.kind")
  446. status = _bounded_text(item.get("status"), "enterprise_bindings.status")
  447. if status not in {"bound", "unbound"}:
  448. raise DomainReplicationError("enterprise binding status must be bound or unbound")
  449. if kind in binding_statuses:
  450. raise DomainReplicationError(f"duplicate enterprise binding: {kind}")
  451. binding_statuses[kind] = status
  452. missing_bindings = sorted(_REQUIRED_ENTERPRISE_BINDINGS - set(binding_statuses))
  453. if missing_bindings:
  454. raise DomainReplicationError(
  455. f"missing enterprise bindings: {','.join(missing_bindings)}"
  456. )
  457. unbound = sorted(
  458. kind for kind, status in binding_statuses.items() if status == "unbound"
  459. )
  460. assessment = manifest["core_change_assessment"]
  461. report: dict[str, Any] = {
  462. "schema_version": 1,
  463. "package_code": package_code,
  464. "status": "passed",
  465. "domain": {
  466. "code": domain_code,
  467. "name": _bounded_text(manifest["domain"].get("name"), "domain.name"),
  468. "template_version": int(
  469. stage_reports["template_initialization"]["metrics"]["template_version"]
  470. ),
  471. },
  472. "incremental_collection": incremental,
  473. "stages": stage_reports,
  474. "device_specific_core_changes": 0,
  475. "generic_extension_count": len(assessment["generic_extensions"]),
  476. "third_domain_reusable": True,
  477. "enterprise_uat": {
  478. "status": "blocked_external" if unbound else "ready",
  479. "unbound_requirements": unbound,
  480. },
  481. }
  482. report["report_sha256"] = _digest(report)
  483. return report