domain_replication.py 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062
  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. import unicodedata
  12. from copy import deepcopy
  13. from datetime import date
  14. from typing import Any
  15. from app.core.governance.domain_replication_contract_registry import verify_registry
  16. REQUIRED_STAGES = (
  17. "template_initialization",
  18. "incremental_ingestion",
  19. "catalog",
  20. "semantics",
  21. "responsibility",
  22. "quality",
  23. "remediation",
  24. "observability",
  25. "data_product",
  26. "agent",
  27. )
  28. STAGE_SUBSYSTEMS = {
  29. "template_initialization": "domain_templates",
  30. "incremental_ingestion": "active_metadata",
  31. "catalog": "active_metadata",
  32. "semantics": "semantic_governance",
  33. "responsibility": "unified_responsibilities",
  34. "quality": "quality_operations",
  35. "remediation": "unified_work_center",
  36. "observability": "data_observability",
  37. "data_product": "product_governance",
  38. "agent": "agent_governance",
  39. }
  40. _CODE = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
  41. _SECRET = re.compile(
  42. r"(?:password|passwd|secret|token|credential|authorization|api[_-]?key)",
  43. re.IGNORECASE,
  44. )
  45. _DEVICE_PATH_MARKERS = (
  46. "app/core/data_research/device_",
  47. "app/api/data_development/device_",
  48. "frontend/src/views/dataResearch/device",
  49. )
  50. _REQUIRED_ENTERPRISE_BINDINGS = {
  51. "data_steward",
  52. "domain_owner",
  53. "readonly_source",
  54. "uat_users",
  55. }
  56. _EXECUTION_ACTIONS = ("dry_run", "execute")
  57. _RECOVERY_ACTIONS = frozenset({"diff", "rollback"})
  58. _EXECUTION_REF = re.compile(
  59. r"^[a-z][a-z0-9-]{1,31}://([a-z][a-z0-9_]{1,63})/[a-zA-Z0-9_./:-]{1,240}$"
  60. )
  61. _HTTP_URL = re.compile(r"https?://", re.IGNORECASE)
  62. _RAW_ROW_KEY = re.compile(r"(?:raw[_-]?rows?|source[_-]?rows?)", re.IGNORECASE)
  63. _SHA256 = re.compile(r"^[0-9a-f]{64}$")
  64. _IDENTIFIER = re.compile(r"^[a-z][a-z0-9_-]{1,79}$")
  65. _CONFUSABLES = str.maketrans({
  66. "а": "a", "е": "e", "і": "i", "о": "o", "р": "p", "с": "c",
  67. "х": "x", "у": "y", "к": "k", "м": "m", "т": "t", "ѕ": "s",
  68. })
  69. _SENSITIVE_KEY_MARKERS = (
  70. "password", "passwd", "secret", "token", "credential", "authorization",
  71. "apikey", "privatekey",
  72. )
  73. _RAW_SOURCE_KEYS = {"rawrows", "sourcerows", "rows"}
  74. _SQL = re.compile(r"\b(?:select|insert|update|delete|drop|alter|create|grant|revoke)\b", re.IGNORECASE)
  75. _API_ALLOWLIST = {
  76. "template_initialization": ("/api/meta/domain-templates",),
  77. "incremental_ingestion": ("/api/meta/active-metadata",),
  78. "catalog": ("/api/meta/active-metadata",),
  79. "semantics": ("/api/development/v1/semantic-assets",),
  80. "responsibility": ("/api/system/responsibilities",),
  81. "quality": ("/api/rules/quality-operations",),
  82. "remediation": ("/api/system/work-center",),
  83. "observability": ("/api/datafactory/observability",),
  84. "data_product": ("/api/dataservice/governance/products",),
  85. "agent": ("/api/knowledge/agents",),
  86. }
  87. _EVIDENCE_PREFIXES = {
  88. "template_initialization": "domain-template://",
  89. "incremental_ingestion": "ingestion://",
  90. "catalog": "catalog-run://",
  91. "semantics": "semantic-release://",
  92. "responsibility": "responsibility://",
  93. "quality": "quality://",
  94. "remediation": "workflow://",
  95. "observability": "incident://",
  96. "data_product": "product://",
  97. "agent": "agent-request://",
  98. }
  99. _CONTRACT_REQUIREMENTS = {
  100. "incremental_ingestion": "p3_wp03_connector_run",
  101. "catalog": "p3_wp03_catalog_lineage",
  102. "observability": "p3_wp04_edge_health",
  103. }
  104. _METRIC_FIELDS = {
  105. "template_initialization": {"template_version", "object_type_count", "rule_count", "role_count", "metric_count"},
  106. "incremental_ingestion": {"snapshot_rows", "delta_rows", "inserted", "updated", "deleted", "final_rows", "replay_changes", "cursor_before", "cursor_after"},
  107. "catalog": {"asset_count", "incremental_change_count", "cursor_after"},
  108. "semantics": {"published_term_count", "published_code_set_count", "published_metric_count", "mapped_field_count"},
  109. "responsibility": {"coverage_percent", "bound_role_count"},
  110. "quality": {"published_rule_count", "initial_finding_count", "target_score", "final_score"},
  111. "remediation": {"issue_status", "task_status", "independent_closer"},
  112. "observability": {"slo_count", "incident_status", "recovered"},
  113. "data_product": {"contract_status", "certificate_status", "certificate_evidence_refs"},
  114. "agent": {"autonomy_level", "decision", "automatic_execution_allowed", "citation_count", "cross_domain_denied"},
  115. }
  116. class DomainReplicationError(ValueError):
  117. """Raised when an implementation package cannot prove a replication gate."""
  118. def _canonical(value: Any) -> bytes:
  119. return json.dumps(
  120. value,
  121. ensure_ascii=False,
  122. sort_keys=True,
  123. separators=(",", ":"),
  124. ).encode("utf-8")
  125. def _digest(value: Any) -> str:
  126. return hashlib.sha256(_canonical(value)).hexdigest()
  127. def _mapping(value: Any, field: str) -> dict[str, Any]:
  128. if not isinstance(value, dict):
  129. raise DomainReplicationError(f"{field} must be an object")
  130. return value
  131. def _bounded_text(value: Any, field: str, limit: int = 256) -> str:
  132. rendered = str(value or "").strip()
  133. if not rendered or len(rendered) > limit:
  134. raise DomainReplicationError(f"{field} must be non-empty and <= {limit}")
  135. return rendered
  136. def _security_key(value: Any) -> str:
  137. return re.sub(
  138. r"[^a-z0-9]", "", unicodedata.normalize("NFKC", str(value)).casefold().translate(_CONFUSABLES)
  139. )
  140. def _reject_secrets(value: Any, path: str = "$", depth: int = 0) -> None:
  141. if depth > 12:
  142. raise DomainReplicationError(f"maximum package depth exceeded at {path}")
  143. if isinstance(value, dict):
  144. if len(value) > 64:
  145. raise DomainReplicationError(f"maximum object properties exceeded at {path}")
  146. for key, item in value.items():
  147. normalized_key = _security_key(key)
  148. if normalized_key in _RAW_SOURCE_KEYS:
  149. raise DomainReplicationError(
  150. f"raw source rows are not allowed at {path}.{key}"
  151. )
  152. if _SECRET.search(normalized_key) or any(
  153. marker in normalized_key for marker in _SENSITIVE_KEY_MARKERS
  154. ):
  155. raise DomainReplicationError(
  156. f"sensitive material is not allowed at {path}.{key}"
  157. )
  158. _reject_secrets(item, f"{path}.{key}", depth + 1)
  159. elif isinstance(value, list):
  160. if len(value) > 500:
  161. raise DomainReplicationError(f"maximum collection items exceeded at {path}")
  162. for index, item in enumerate(value):
  163. _reject_secrets(item, f"{path}[{index}]", depth + 1)
  164. elif isinstance(value, str):
  165. if len(value) > 4096:
  166. raise DomainReplicationError(f"maximum string length exceeded at {path}")
  167. normalized = unicodedata.normalize("NFKC", value)
  168. if _HTTP_URL.search(normalized):
  169. raise DomainReplicationError(f"URL is not allowed at {path}")
  170. if _SQL.search(normalized):
  171. raise DomainReplicationError(f"SQL is not allowed at {path}")
  172. def _closed_mapping(value: Any, field: str, allowed: set[str]) -> dict[str, Any]:
  173. value = _mapping(value, field)
  174. unknown = sorted(set(value) - allowed)
  175. if unknown:
  176. normalized_unknown = [_security_key(key) for key in unknown]
  177. if any(key in _RAW_SOURCE_KEYS for key in normalized_unknown):
  178. raise DomainReplicationError(f"raw source rows are not allowed at {field}")
  179. if any(
  180. _SECRET.search(key) or any(marker in key for marker in _SENSITIVE_KEY_MARKERS)
  181. for key in normalized_unknown
  182. ):
  183. raise DomainReplicationError(f"sensitive material is not allowed at {field}")
  184. raise DomainReplicationError(f"{field} has unsupported fields: {unknown}")
  185. return value
  186. def _closed_required(value: Any, field: str, required: set[str], allowed: set[str]) -> dict[str, Any]:
  187. value = _closed_mapping(value, field, allowed)
  188. missing = sorted(required - set(value))
  189. if missing:
  190. raise DomainReplicationError(f"{field} is missing required fields: {missing}")
  191. return value
  192. def _string(value: Any, field: str, limit: int = 256) -> str:
  193. if not isinstance(value, str):
  194. raise DomainReplicationError(f"{field} must be a string")
  195. return _bounded_text(value, field, limit)
  196. def _digest_string(value: Any, field: str) -> str:
  197. value = _string(value, field, 64)
  198. if not _SHA256.fullmatch(value):
  199. raise DomainReplicationError(f"{field} must be a lowercase SHA-256 digest")
  200. return value
  201. def _bounded_list(value: Any, field: str, *, minimum: int = 1, maximum: int = 100) -> list[Any]:
  202. if not isinstance(value, list) or not minimum <= len(value) <= maximum:
  203. raise DomainReplicationError(f"{field} must contain {minimum}..{maximum} items")
  204. return value
  205. def _execution_ref(value: Any, field: str) -> tuple[str, str]:
  206. rendered = _bounded_text(value, field, 320)
  207. if _HTTP_URL.search(rendered):
  208. raise DomainReplicationError(f"{field} must not contain a URL")
  209. matched = _EXECUTION_REF.fullmatch(rendered)
  210. if not matched:
  211. raise DomainReplicationError(f"{field} must be a canonical evidence reference")
  212. return rendered, matched.group(1)
  213. def _reject_execution_sensitive(value: Any, path: str = "execution_evidence") -> None:
  214. if isinstance(value, dict):
  215. for key, item in value.items():
  216. if _RAW_ROW_KEY.search(str(key)):
  217. raise DomainReplicationError(f"raw source rows are not allowed at {path}.{key}")
  218. if _HTTP_URL.search(str(item)):
  219. raise DomainReplicationError(f"URL is not allowed at {path}.{key}")
  220. _reject_execution_sensitive(item, f"{path}.{key}")
  221. elif isinstance(value, list):
  222. for index, item in enumerate(value):
  223. _reject_execution_sensitive(item, f"{path}[{index}]")
  224. def _validate_execute_state(action: dict[str, Any], stage: str) -> dict[str, Any]:
  225. """Validate the v2 receipt that owns a governed state transition."""
  226. required = {
  227. "action", "status", "write_count", "evidence_ref", "audit_ref", "operation_uid",
  228. "idempotency_key", "request_digest", "attempt", "lease_fence", "pre_state_digest",
  229. "post_state_digest", "result_digest",
  230. }
  231. action = _closed_required(action, f"{stage}.execute", required, required)
  232. for field in ("operation_uid", "idempotency_key"):
  233. if not _IDENTIFIER.fullmatch(_string(action[field], f"{stage}.execute.{field}", 80)):
  234. raise DomainReplicationError(f"{stage}.execute.{field} is invalid")
  235. for field in ("request_digest", "pre_state_digest", "post_state_digest", "result_digest"):
  236. _digest_string(action[field], f"{stage}.execute.{field}")
  237. if action["pre_state_digest"] == action["post_state_digest"]:
  238. raise DomainReplicationError(f"{stage}.execute state digests must differ")
  239. if isinstance(action["attempt"], bool) or not isinstance(action["attempt"], int) or not 1 <= action["attempt"] <= 5:
  240. raise DomainReplicationError(f"{stage}.execute.attempt must be 1..5")
  241. if isinstance(action["lease_fence"], bool) or not isinstance(action["lease_fence"], int) or action["lease_fence"] < 1:
  242. raise DomainReplicationError(f"{stage}.execute.lease_fence must be positive")
  243. return action
  244. def _validate_rollback(action: dict[str, Any], stage: str, *, strict_state_machine: bool) -> dict[str, Any]:
  245. required = {
  246. "action", "status", "write_count", "evidence_ref", "audit_ref", "operation",
  247. "idempotency_key", "request_digest", "attempt", "lease_fence", "from_digest",
  248. "to_digest", "replay_status",
  249. }
  250. if strict_state_machine:
  251. required = required - {"replay_status"} | {"operation_uid", "result_digest"}
  252. action = _closed_required(action, f"{stage}.rollback", required, required)
  253. if action["operation"] != "restore":
  254. raise DomainReplicationError(f"{stage}.rollback.operation must be restore")
  255. for field in ("operation_uid", "idempotency_key") if strict_state_machine else ("idempotency_key",):
  256. if not _IDENTIFIER.fullmatch(_string(action[field], f"{stage}.rollback.{field}", 80)):
  257. raise DomainReplicationError(f"{stage}.rollback.{field} is invalid")
  258. digest_fields = ("request_digest", "from_digest", "to_digest", "result_digest") if strict_state_machine else ("request_digest", "from_digest", "to_digest")
  259. for key in digest_fields:
  260. _digest_string(action[key], f"{stage}.rollback.{key}")
  261. if action["from_digest"] == action["to_digest"]:
  262. raise DomainReplicationError(f"{stage}.rollback digests must differ")
  263. if isinstance(action["attempt"], bool) or not isinstance(action["attempt"], int) or not 1 <= action["attempt"] <= 5:
  264. raise DomainReplicationError(f"{stage}.rollback.attempt must be 1..5")
  265. if isinstance(action["lease_fence"], bool) or not isinstance(action["lease_fence"], int) or action["lease_fence"] < 1:
  266. raise DomainReplicationError(f"{stage}.rollback.lease_fence must be positive")
  267. if not strict_state_machine and action["replay_status"] != "exact_replay":
  268. raise DomainReplicationError(f"{stage}.rollback replay must be exact_replay")
  269. return action
  270. def _validate_replay_receipts(value: Any, rollbacks: dict[str, dict[str, Any]]) -> None:
  271. """Require a second receipt, not a rollback's self-declared replay label."""
  272. required = {
  273. "stage", "operation_uid", "idempotency_key", "request_digest", "attempt",
  274. "lease_fence", "from_digest", "to_digest", "result_digest", "status",
  275. }
  276. receipts = _bounded_list(value, "execution_evidence.replay_receipts", minimum=len(rollbacks), maximum=len(rollbacks))
  277. by_stage: dict[str, dict[str, Any]] = {}
  278. for item in receipts:
  279. item = _closed_required(item, "execution_evidence.replay_receipt", required, required)
  280. stage = _string(item["stage"], "execution_evidence.replay_receipt.stage", 80)
  281. if stage not in rollbacks or stage in by_stage:
  282. raise DomainReplicationError("replay receipt must bind exactly one rollback stage")
  283. if item["status"] != "exact_replay":
  284. raise DomainReplicationError("replay receipt status must be exact_replay")
  285. for field in ("operation_uid", "idempotency_key"):
  286. if not _IDENTIFIER.fullmatch(_string(item[field], f"replay_receipt.{field}", 80)):
  287. raise DomainReplicationError(f"replay receipt {field} is invalid")
  288. for field in ("request_digest", "from_digest", "to_digest", "result_digest"):
  289. _digest_string(item[field], f"replay_receipt.{field}")
  290. rollback = rollbacks[stage]
  291. for field in required - {"stage", "status"}:
  292. if item[field] != rollback[field]:
  293. raise DomainReplicationError(f"replay receipt does not match rollback {field}")
  294. by_stage[stage] = item
  295. if set(by_stage) != set(rollbacks):
  296. raise DomainReplicationError("replay receipt coverage is incomplete")
  297. def validate_execution_evidence(
  298. execution_evidence: dict[str, Any], *, domain_code: str | None = None
  299. ) -> dict[str, dict[str, Any]]:
  300. """Validate the ordered, row-free execution chain for every governance stage."""
  301. initial = _mapping(execution_evidence, "execution_evidence")
  302. schema_version = initial.get("schema_version")
  303. if schema_version not in {1, 2}:
  304. raise DomainReplicationError("execution_evidence.schema_version must be 1 or 2")
  305. strict_state_machine = schema_version == 2
  306. execution_evidence = _closed_mapping(
  307. execution_evidence,
  308. "execution_evidence",
  309. {"schema_version", "stages", "replay_receipts"} if strict_state_machine else {"schema_version", "stages"},
  310. )
  311. _reject_secrets(execution_evidence, "execution_evidence")
  312. _reject_execution_sensitive(execution_evidence)
  313. receipts = execution_evidence.get("stages")
  314. if not isinstance(receipts, list) or len(receipts) != len(REQUIRED_STAGES):
  315. raise DomainReplicationError("execution_evidence must contain every required stage")
  316. summaries: dict[str, dict[str, Any]] = {}
  317. rollbacks: dict[str, dict[str, Any]] = {}
  318. for expected_stage, receipt in zip(REQUIRED_STAGES, receipts, strict=True):
  319. receipt = _closed_mapping(receipt, "execution_evidence.stage", {"stage", "actions"})
  320. stage = _bounded_text(receipt.get("stage"), "execution_evidence.stage.stage", 80)
  321. if stage != expected_stage:
  322. raise DomainReplicationError("execution evidence stages must be ordered")
  323. actions = receipt.get("actions")
  324. if not isinstance(actions, list) or len(actions) != 3:
  325. raise DomainReplicationError(f"{stage} must contain dry_run, execute and diff or rollback")
  326. normalized_actions: list[dict[str, Any]] = []
  327. domains: set[str] = set()
  328. for index, action in enumerate(actions):
  329. action = _mapping(action, f"{stage}.actions[{index}]")
  330. action_name = _bounded_text(action.get("action"), f"{stage}.action", 32)
  331. expected_action = (
  332. _EXECUTION_ACTIONS[index] if index < 2 else None
  333. )
  334. if expected_action and action_name != expected_action:
  335. raise DomainReplicationError(f"{stage} actions must be ordered dry_run then execute")
  336. if index == 2 and action_name not in _RECOVERY_ACTIONS:
  337. raise DomainReplicationError(f"{stage} recovery action must be diff or rollback")
  338. allowed = {"action", "status", "write_count", "evidence_ref", "audit_ref"}
  339. if action_name == "rollback":
  340. action = _validate_rollback(action, stage, strict_state_machine=strict_state_machine)
  341. elif action_name == "execute" and strict_state_machine:
  342. action = _validate_execute_state(action, stage)
  343. else:
  344. action = _closed_required(action, f"{stage}.actions[{index}]", allowed, allowed)
  345. if action.get("status") != "passed":
  346. raise DomainReplicationError(f"{stage}.{action_name} status must be passed")
  347. write_count = action.get("write_count")
  348. if isinstance(write_count, bool) or not isinstance(write_count, int):
  349. raise DomainReplicationError(f"{stage}.{action_name}.write_count must be an integer")
  350. if write_count < 0 or write_count > 1_000_000:
  351. raise DomainReplicationError(f"{stage}.{action_name}.write_count is out of range")
  352. if action_name == "dry_run" and write_count != 0:
  353. raise DomainReplicationError(f"{stage}.dry_run must record zero writes")
  354. evidence_ref, evidence_domain = _execution_ref(
  355. action.get("evidence_ref"), f"{stage}.{action_name}.evidence_ref"
  356. )
  357. audit_ref, audit_domain = _execution_ref(
  358. action.get("audit_ref"), f"{stage}.{action_name}.audit_ref"
  359. )
  360. domains.update({evidence_domain, audit_domain})
  361. normalized_actions.append(
  362. {
  363. "action": action_name,
  364. "write_count": write_count,
  365. "evidence_ref": evidence_ref,
  366. "audit_ref": audit_ref,
  367. }
  368. )
  369. if action_name == "rollback" and strict_state_machine:
  370. execute = actions[1]
  371. for field in ("operation_uid", "idempotency_key", "request_digest", "attempt", "lease_fence"):
  372. if action[field] != execute[field]:
  373. raise DomainReplicationError(f"{stage}.rollback must match execute {field}")
  374. if action["from_digest"] != execute["post_state_digest"]:
  375. raise DomainReplicationError(f"{stage}.rollback.from_digest must match execute post_state_digest")
  376. if action["to_digest"] != execute["pre_state_digest"]:
  377. raise DomainReplicationError(f"{stage}.rollback.to_digest must match execute pre_state_digest")
  378. rollbacks[stage] = action
  379. if len(domains) != 1 or (domain_code is not None and domains != {domain_code}):
  380. raise DomainReplicationError(f"{stage} execution evidence contains a cross-domain reference")
  381. summaries[stage] = {
  382. "actions": [item["action"] for item in normalized_actions],
  383. "dry_run_write_count": normalized_actions[0]["write_count"],
  384. "execute_write_count": normalized_actions[1]["write_count"],
  385. "recovery_strategy": normalized_actions[2]["action"],
  386. "recovery_write_count": normalized_actions[2]["write_count"],
  387. "audit_ref_count": len(normalized_actions),
  388. "evidence_digest": _digest(normalized_actions),
  389. }
  390. if strict_state_machine:
  391. _validate_replay_receipts(execution_evidence.get("replay_receipts"), rollbacks)
  392. return summaries
  393. def _row_key(row: dict[str, str], fields: list[str]) -> tuple[str, ...]:
  394. values = tuple(str(row.get(field, "")).strip() for field in fields)
  395. if any(not value for value in values):
  396. raise DomainReplicationError("source primary key values must be non-empty")
  397. return values
  398. def _normalized_row(
  399. row: dict[str, str], *, operation_field: str, cursor_field: str
  400. ) -> dict[str, str]:
  401. return {
  402. str(key): str(value or "").strip()
  403. for key, value in sorted(row.items())
  404. if key not in {operation_field, cursor_field}
  405. }
  406. def _validate_source_fields(rows: list[dict[str, str]]) -> None:
  407. for row in rows:
  408. if not isinstance(row, dict):
  409. raise DomainReplicationError("source rows must be objects")
  410. for field in row:
  411. normalized = _security_key(field)
  412. if normalized in _RAW_SOURCE_KEYS or _SECRET.search(normalized) or any(
  413. marker in normalized for marker in _SENSITIVE_KEY_MARKERS
  414. ):
  415. raise DomainReplicationError(
  416. f"secret-like source field is not allowed: {field}"
  417. )
  418. def collect_incremental_rows(
  419. snapshot_rows: list[dict[str, str]],
  420. delta_rows: list[dict[str, str]],
  421. source: dict[str, Any],
  422. ) -> dict[str, Any]:
  423. """Apply a controlled snapshot and monotonic delta without exposing rows."""
  424. source = _mapping(source, "source")
  425. if source.get("kind") != "controlled_csv":
  426. raise DomainReplicationError("source.kind must be controlled_csv")
  427. if source.get("classification") != "desensitized":
  428. raise DomainReplicationError("source.classification must be desensitized")
  429. primary_key = source.get("primary_key")
  430. if not isinstance(primary_key, list) or not primary_key:
  431. raise DomainReplicationError("source.primary_key must be a non-empty list")
  432. primary_key = [_bounded_text(item, "source.primary_key") for item in primary_key]
  433. operation_field = _bounded_text(
  434. source.get("operation_field", "_operation"), "source.operation_field"
  435. )
  436. cursor_field = _bounded_text(
  437. source.get("cursor_field", "_cursor"), "source.cursor_field"
  438. )
  439. required_columns = source.get("required_columns")
  440. if not isinstance(required_columns, list) or not required_columns:
  441. raise DomainReplicationError("source.required_columns must be a non-empty list")
  442. required_columns = {
  443. _bounded_text(item, "source.required_columns") for item in required_columns
  444. }
  445. cursor_before = int(source.get("snapshot_cursor", -1))
  446. if cursor_before < 0:
  447. raise DomainReplicationError("source.snapshot_cursor must be non-negative")
  448. if not snapshot_rows:
  449. raise DomainReplicationError("controlled snapshot must contain rows")
  450. if not delta_rows:
  451. raise DomainReplicationError("controlled delta must contain rows")
  452. _validate_source_fields(snapshot_rows)
  453. _validate_source_fields(delta_rows)
  454. state: dict[tuple[str, ...], dict[str, str]] = {}
  455. for row in snapshot_rows:
  456. if not required_columns.issubset(row):
  457. missing = sorted(required_columns - set(row))
  458. raise DomainReplicationError(f"snapshot is missing required columns: {missing}")
  459. key = _row_key(row, primary_key)
  460. if key in state:
  461. raise DomainReplicationError("snapshot primary keys must be unique")
  462. state[key] = _normalized_row(
  463. row, operation_field=operation_field, cursor_field=cursor_field
  464. )
  465. cursors: list[int] = []
  466. for row in delta_rows:
  467. if cursor_field not in row or operation_field not in row:
  468. raise DomainReplicationError("delta cursor and operation fields are required")
  469. try:
  470. cursor = int(row[cursor_field])
  471. except (TypeError, ValueError) as exc:
  472. raise DomainReplicationError("delta cursor must be an integer") from exc
  473. if cursor <= cursor_before or (cursors and cursor <= cursors[-1]):
  474. raise DomainReplicationError("delta cursors must strictly increase")
  475. cursors.append(cursor)
  476. def apply_delta() -> dict[str, int]:
  477. result = {"inserted": 0, "updated": 0, "deleted": 0}
  478. for row in delta_rows:
  479. operation = str(row[operation_field]).strip().lower()
  480. if operation not in {"upsert", "delete"}:
  481. raise DomainReplicationError(f"unsupported delta operation: {operation}")
  482. key = _row_key(row, primary_key)
  483. if operation == "delete":
  484. if key in state:
  485. del state[key]
  486. result["deleted"] += 1
  487. continue
  488. if not required_columns.issubset(row):
  489. missing = sorted(required_columns - set(row))
  490. raise DomainReplicationError(f"delta is missing required columns: {missing}")
  491. normalized = _normalized_row(
  492. row, operation_field=operation_field, cursor_field=cursor_field
  493. )
  494. if key not in state:
  495. state[key] = normalized
  496. result["inserted"] += 1
  497. elif state[key] != normalized:
  498. state[key] = normalized
  499. result["updated"] += 1
  500. return result
  501. first = apply_delta()
  502. first_state = deepcopy(state)
  503. replay = apply_delta()
  504. if state != first_state:
  505. raise DomainReplicationError("delta replay changed final source state")
  506. replay_changes = sum(replay.values())
  507. final_rows = [state[key] for key in sorted(state)]
  508. return {
  509. "source_kind": "controlled_csv",
  510. "classification": "desensitized",
  511. "snapshot_rows": len(snapshot_rows),
  512. "delta_rows": len(delta_rows),
  513. **first,
  514. "final_rows": len(state),
  515. "replay_changes": replay_changes,
  516. "cursor_before": cursor_before,
  517. "cursor_after": cursors[-1],
  518. "snapshot_digest": _digest(
  519. sorted(
  520. (
  521. _normalized_row(
  522. row,
  523. operation_field=operation_field,
  524. cursor_field=cursor_field,
  525. )
  526. for row in snapshot_rows
  527. ),
  528. key=_canonical,
  529. )
  530. ),
  531. "delta_digest": _digest(
  532. [
  533. {
  534. **_normalized_row(
  535. row,
  536. operation_field=operation_field,
  537. cursor_field=cursor_field,
  538. ),
  539. operation_field: row[operation_field],
  540. cursor_field: int(row[cursor_field]),
  541. }
  542. for row in delta_rows
  543. ]
  544. ),
  545. "final_digest": _digest(final_rows),
  546. }
  547. def _positive(metrics: dict[str, Any], field: str, minimum: float = 1) -> float:
  548. try:
  549. value = float(metrics.get(field, 0))
  550. except (TypeError, ValueError) as exc:
  551. raise DomainReplicationError(f"{field} must be numeric") from exc
  552. if value < minimum:
  553. raise DomainReplicationError(f"{field} must be >= {minimum:g}")
  554. return value
  555. def _validate_stage_metrics(
  556. stage: str,
  557. metrics: dict[str, Any],
  558. *,
  559. template: dict[str, Any],
  560. incremental: dict[str, Any],
  561. ) -> None:
  562. if stage == "template_initialization":
  563. expected = {
  564. "object_type_count": len(template.get("object_types", [])),
  565. "rule_count": len(template.get("rules", [])),
  566. "role_count": len(template.get("responsibility_roles", [])),
  567. "metric_count": len(template.get("metrics", [])),
  568. }
  569. if any(int(metrics.get(key, -1)) != value for key, value in expected.items()):
  570. raise DomainReplicationError("template initialization counts do not match template")
  571. _positive(metrics, "template_version")
  572. elif stage == "incremental_ingestion":
  573. for key in (
  574. "snapshot_rows",
  575. "delta_rows",
  576. "inserted",
  577. "updated",
  578. "deleted",
  579. "final_rows",
  580. "replay_changes",
  581. "cursor_before",
  582. "cursor_after",
  583. ):
  584. if int(metrics.get(key, -1)) != int(incremental[key]):
  585. raise DomainReplicationError(
  586. f"incremental ingestion evidence does not match collected {key}"
  587. )
  588. elif stage == "catalog":
  589. _positive(metrics, "asset_count", 3)
  590. _positive(metrics, "incremental_change_count")
  591. if int(metrics.get("cursor_after", -1)) != incremental["cursor_after"]:
  592. raise DomainReplicationError("catalog cursor does not match ingestion cursor")
  593. elif stage == "semantics":
  594. for field in (
  595. "published_term_count",
  596. "published_code_set_count",
  597. "published_metric_count",
  598. "mapped_field_count",
  599. ):
  600. _positive(metrics, field)
  601. elif stage == "responsibility":
  602. if float(metrics.get("coverage_percent", 0)) != 100:
  603. raise DomainReplicationError("responsibility coverage must be 100 percent")
  604. _positive(metrics, "bound_role_count", 3)
  605. elif stage == "quality":
  606. _positive(metrics, "published_rule_count", 5)
  607. _positive(metrics, "initial_finding_count")
  608. target = float(metrics.get("target_score", 0))
  609. final = float(metrics.get("final_score", 0))
  610. if target <= 0 or final < target:
  611. raise DomainReplicationError("quality final score must meet target score")
  612. elif stage == "remediation":
  613. expected = {
  614. "issue_status": "closed",
  615. "task_status": "completed",
  616. "independent_closer": True,
  617. }
  618. if metrics != expected:
  619. raise DomainReplicationError("remediation issue and task must be independently closed")
  620. elif stage == "observability":
  621. _positive(metrics, "slo_count")
  622. if metrics.get("incident_status") != "closed" or metrics.get("recovered") is not True:
  623. raise DomainReplicationError("observability incident must recover and close")
  624. elif stage == "data_product":
  625. if metrics.get("contract_status") != "active":
  626. raise DomainReplicationError("data product contract must be active")
  627. if metrics.get("certificate_status") != "issued":
  628. raise DomainReplicationError("data product certificate must be issued")
  629. refs = metrics.get("certificate_evidence_refs")
  630. if not isinstance(refs, list) or len(refs) < 4:
  631. raise DomainReplicationError("certificate evidence must bind quality, lineage, rule and workflow")
  632. required = ("quality://", "lineage://", "rule://", "workflow://")
  633. if not all(any(str(ref).startswith(prefix) for ref in refs) for prefix in required):
  634. raise DomainReplicationError("certificate evidence is missing canonical references")
  635. elif stage == "agent":
  636. if metrics.get("autonomy_level") not in {"read_only", "suggestion"}:
  637. raise DomainReplicationError("agent autonomy must be read_only or suggestion")
  638. if metrics.get("decision") != "authorized":
  639. raise DomainReplicationError("agent action must be authorized")
  640. if metrics.get("automatic_execution_allowed") is not False:
  641. raise DomainReplicationError("agent automatic execution must remain disabled")
  642. _positive(metrics, "citation_count")
  643. if metrics.get("cross_domain_denied") is not True:
  644. raise DomainReplicationError("agent cross-domain request must be denied")
  645. def _validate_seed_data(seed_data: Any, *, schema_version: int) -> None:
  646. payload_key = "records" if schema_version == 1 else "items"
  647. record_fields = {
  648. "source_contract": {"id", "type", "binding_status"},
  649. "term_and_code_set": {"id", "name"},
  650. "workflow_contract": {"id", "name"},
  651. }
  652. for item in _bounded_list(seed_data, "template.seed_data", minimum=0):
  653. item = _closed_required(
  654. item,
  655. "template.seed_data.item",
  656. {"kind", payload_key},
  657. {"kind", payload_key},
  658. )
  659. kind = _string(item["kind"], "template.seed_data.kind", 80)
  660. if kind not in record_fields:
  661. raise DomainReplicationError("template.seed_data.kind is unsupported")
  662. for record in _bounded_list(item[payload_key], f"template.seed_data.{payload_key}", minimum=1):
  663. record = _closed_required(
  664. record,
  665. "template.seed_data.record",
  666. record_fields[kind],
  667. record_fields[kind],
  668. )
  669. for key in record_fields[kind]:
  670. _string(record[key], f"template.seed_data.record.{key}", 200)
  671. def _validate_template(template: dict[str, Any], *, schema_version: int) -> dict[str, Any]:
  672. required = {
  673. "template_code", "name", "description", "lifecycle_status", "object_types",
  674. "responsibility_roles", "rules", "metrics", "seed_data",
  675. }
  676. _reject_secrets(template, "template")
  677. template = _closed_required(template, "template", required, required)
  678. _string(template["template_code"], "template.template_code", 64)
  679. _string(template["name"], "template.name", 200)
  680. _string(template["description"], "template.description", 500)
  681. if template["lifecycle_status"] not in {"draft", "active", "retired"}:
  682. raise DomainReplicationError("template.lifecycle_status is invalid")
  683. for item in _bounded_list(template["object_types"], "template.object_types", minimum=3):
  684. item = _closed_required(
  685. item,
  686. "template.object_types.item",
  687. {"type_code", "name", "stable_uid_prefix", "source_identity_fields", "fields"},
  688. {"type_code", "name", "description", "stable_uid_prefix", "source_identity_fields", "fields"},
  689. )
  690. for key in ("type_code", "name", "stable_uid_prefix"):
  691. _string(item[key], f"template.object_types.{key}", 200)
  692. for identity in _bounded_list(item["source_identity_fields"], "template.source_identity_fields"):
  693. _string(identity, "template.source_identity_fields.item", 64)
  694. for field in _bounded_list(item["fields"], "template.fields"):
  695. field = _closed_required(
  696. field,
  697. "template.fields.item",
  698. {"code", "name", "type", "required"},
  699. {"code", "name", "type", "required", "description"},
  700. )
  701. if not isinstance(field["required"], bool):
  702. raise DomainReplicationError("template.fields.required must be boolean")
  703. for key in ("code", "name", "type"):
  704. _string(field[key], f"template.fields.{key}", 200)
  705. for collection, required_fields in (
  706. ("responsibility_roles", {"code", "name", "raci_role"}),
  707. ("rules", {"code", "name", "dimension", "target_object"}),
  708. ("metrics", {"code", "name", "unit"}),
  709. ):
  710. for item in _bounded_list(template[collection], f"template.{collection}"):
  711. item = _closed_required(item, f"template.{collection}.item", required_fields, required_fields)
  712. for key in required_fields:
  713. _string(item[key], f"template.{collection}.{key}", 200)
  714. _validate_seed_data(template["seed_data"], schema_version=schema_version)
  715. return template
  716. def _validate_contract_refs(stage: str, value: Any) -> list[dict[str, Any]]:
  717. if stage not in _CONTRACT_REQUIREMENTS:
  718. if value is not None:
  719. raise DomainReplicationError(f"{stage} does not accept contract_refs")
  720. return []
  721. refs = _bounded_list(value, f"{stage}.contract_refs", minimum=1, maximum=1)
  722. return [validate_contract_reference(refs[0], expected_contract_id=_CONTRACT_REQUIREMENTS[stage])]
  723. def validate_contract_reference(
  724. reference: dict[str, Any], *, expected_contract_id: str
  725. ) -> dict[str, Any]:
  726. """Resolve one v2 receipt only against the code-side trusted registry."""
  727. required = {
  728. "contract_id", "contract_version", "schema_digest", "operation_uid", "run_id",
  729. "operation", "attempt", "lease_fence", "request_digest", "result_digest", "status",
  730. }
  731. reference = _closed_required(reference, "contract_ref", required, required)
  732. try:
  733. registry = verify_registry()
  734. except ValueError as exc:
  735. raise DomainReplicationError("trusted contract registry integrity check failed") from exc
  736. if registry["trust_level"] != "ENGINEERING_EVIDENCE_ONLY":
  737. raise DomainReplicationError("trusted contract registry has an invalid trust level")
  738. if date.fromisoformat(registry["expires_on"]) < date.today():
  739. raise DomainReplicationError("trusted contract registry is expired")
  740. contract_id = _string(reference["contract_id"], "contract_ref.contract_id", 80)
  741. if contract_id != expected_contract_id:
  742. raise DomainReplicationError("cross-contract reference was rejected")
  743. contract = next(
  744. (item for item in registry["contracts"] if item["contract_id"] == contract_id), None
  745. )
  746. if contract is None:
  747. raise DomainReplicationError("contract is absent from the trusted registry")
  748. if reference["contract_version"] != contract["contract_version"]:
  749. raise DomainReplicationError("contract version does not match the trusted registry")
  750. if reference["schema_digest"] != contract["schema_digest"]:
  751. raise DomainReplicationError("contract schema digest does not match the trusted registry")
  752. if reference["operation"] not in contract["allowed_operations"]:
  753. raise DomainReplicationError("contract operation is not allowed")
  754. run = next(
  755. (
  756. item for item in contract["runs"]
  757. if item["operation_uid"] == reference["operation_uid"]
  758. and item["run_id"] == reference["run_id"]
  759. ),
  760. None,
  761. )
  762. if run is None:
  763. raise DomainReplicationError("run is absent from the trusted registry")
  764. for field in (
  765. "operation_uid", "run_id", "operation", "attempt", "lease_fence",
  766. "request_digest", "result_digest", "status",
  767. ):
  768. if reference[field] != run[field]:
  769. raise DomainReplicationError(f"contract registry mismatch for {field}")
  770. for field in ("schema_digest", "request_digest", "result_digest"):
  771. _digest_string(reference[field], f"contract_ref.{field}")
  772. return deepcopy(reference)
  773. def _validate_terminal_evidence(
  774. evidence: dict[str, Any], package_code: str, domain_code: str, template: dict[str, Any], incremental: dict[str, Any], *, schema_version: int
  775. ) -> dict[str, dict[str, Any]]:
  776. _reject_secrets(evidence, "evidence")
  777. evidence = _closed_required(evidence, "evidence", {"schema_version", "package_code", "receipts"}, {"schema_version", "package_code", "receipts"})
  778. if evidence["schema_version"] != 1 or evidence["package_code"] != package_code:
  779. raise DomainReplicationError("evidence package identity is invalid")
  780. receipts = _bounded_list(evidence["receipts"], "evidence.receipts", minimum=1, maximum=len(REQUIRED_STAGES))
  781. by_stage: dict[str, dict[str, Any]] = {}
  782. for receipt in receipts:
  783. receipt = _mapping(receipt, "receipt")
  784. stage = _string(receipt.get("stage"), "receipt.stage", 80)
  785. required = {"stage", "domain_code", "status", "subsystem", "api_refs", "evidence_refs", "metrics"}
  786. allowed = set(required)
  787. if schema_version == 2 and stage in _CONTRACT_REQUIREMENTS:
  788. required.add("contract_refs")
  789. allowed.add("contract_refs")
  790. receipt = _closed_required(receipt, "receipt", required, allowed)
  791. if stage in by_stage:
  792. raise DomainReplicationError(f"duplicate evidence stage: {stage}")
  793. by_stage[stage] = receipt
  794. missing = sorted(set(REQUIRED_STAGES) - set(by_stage))
  795. extra = sorted(set(by_stage) - set(REQUIRED_STAGES))
  796. if missing:
  797. raise DomainReplicationError(f"missing stages: {','.join(missing)}")
  798. if extra:
  799. raise DomainReplicationError(f"unsupported stages: {','.join(extra)}")
  800. reports: dict[str, dict[str, Any]] = {}
  801. for stage in REQUIRED_STAGES:
  802. receipt = by_stage[stage]
  803. if receipt["domain_code"] != domain_code:
  804. raise DomainReplicationError(f"{stage} evidence has the wrong domain")
  805. if receipt["status"] != "passed":
  806. raise DomainReplicationError(f"{stage} evidence status must be passed")
  807. if receipt["subsystem"] != STAGE_SUBSYSTEMS[stage]:
  808. raise DomainReplicationError(f"{stage} evidence subsystem is invalid")
  809. api_refs = _bounded_list(receipt["api_refs"], f"{stage}.api_refs", maximum=1)
  810. if any("/device-" in str(ref) for ref in api_refs):
  811. raise DomainReplicationError("device-specific API evidence is not allowed")
  812. if tuple(api_refs) != _API_ALLOWLIST[stage]:
  813. raise DomainReplicationError(f"{stage} must use its allowlisted API")
  814. evidence_refs = _bounded_list(receipt["evidence_refs"], f"{stage}.evidence_refs", maximum=8)
  815. prefix = _EVIDENCE_PREFIXES[stage]
  816. if any(not isinstance(ref, str) or not ref.startswith(prefix + domain_code + "/") for ref in evidence_refs):
  817. raise DomainReplicationError(f"{stage} evidence_refs are not canonical")
  818. metrics = _closed_required(receipt["metrics"], f"{stage}.metrics", _METRIC_FIELDS[stage], _METRIC_FIELDS[stage])
  819. _validate_stage_metrics(stage, metrics, template=template, incremental=incremental)
  820. contracts = (
  821. _validate_contract_refs(stage, receipt.get("contract_refs"))
  822. if schema_version == 2
  823. else []
  824. )
  825. reports[stage] = {"status": "passed", "subsystem": STAGE_SUBSYSTEMS[stage], "api_refs": list(api_refs), "evidence_refs": sorted(evidence_refs), "metrics": deepcopy(metrics)}
  826. if contracts:
  827. reports[stage]["contract_refs"] = contracts
  828. return reports
  829. def _validate_manifest(
  830. manifest: dict[str, Any], template: dict[str, Any]
  831. ) -> tuple[str, str, int]:
  832. required = {
  833. "schema_version", "package_code", "domain", "files", "source",
  834. "core_change_assessment", "enterprise_bindings", "third_domain_reuse",
  835. }
  836. _reject_secrets(manifest)
  837. manifest = _closed_required(manifest, "manifest", required, required)
  838. if isinstance(manifest["schema_version"], bool) or not isinstance(manifest["schema_version"], int):
  839. raise DomainReplicationError("manifest.schema_version must be an integer")
  840. schema_version = manifest["schema_version"]
  841. if schema_version not in {1, 2}:
  842. raise DomainReplicationError("manifest.schema_version must be 1 or 2")
  843. package_code = _string(manifest["package_code"], "package_code", 80)
  844. domain = _closed_required(manifest["domain"], "manifest.domain", {"code", "name"}, {"code", "name"})
  845. domain_code = _string(domain["code"], "domain.code", 64)
  846. if not _CODE.fullmatch(domain_code):
  847. raise DomainReplicationError("domain.code must be a stable lowercase code")
  848. if template.get("template_code") != domain_code:
  849. raise DomainReplicationError("domain template code does not match package domain")
  850. if len(template.get("object_types", [])) < 3:
  851. raise DomainReplicationError("domain template must contain at least three object types")
  852. if len(template.get("rules", [])) < 5:
  853. raise DomainReplicationError("domain template must contain at least five rules")
  854. files = _mapping(manifest["files"], "manifest.files")
  855. expected_file_keys = {"template", "evidence", "snapshot", "delta", "acceptance_report"}
  856. if schema_version == 2:
  857. expected_file_keys.add("execution_evidence")
  858. if set(files) != expected_file_keys:
  859. raise DomainReplicationError("manifest.files has unsupported or missing bindings")
  860. for key, binding in files.items():
  861. binding = _closed_required(binding, f"manifest.files.{key}", {"path", "sha256"}, {"path", "sha256"})
  862. _string(binding["path"], f"manifest.files.{key}.path", 240)
  863. _digest_string(binding["sha256"], f"manifest.files.{key}.sha256")
  864. source = _closed_required(
  865. manifest["source"], "manifest.source",
  866. {"kind", "classification", "primary_key", "required_columns", "operation_field", "cursor_field", "snapshot_cursor"},
  867. {"kind", "classification", "primary_key", "required_columns", "operation_field", "cursor_field", "snapshot_cursor"},
  868. )
  869. if source["kind"] != "controlled_csv" or source["classification"] != "desensitized":
  870. raise DomainReplicationError("manifest source is invalid")
  871. for field in ("primary_key", "required_columns"):
  872. for value in _bounded_list(source[field], f"manifest.source.{field}"):
  873. _string(value, f"manifest.source.{field}.item", 80)
  874. for field in ("operation_field", "cursor_field"):
  875. _string(source[field], f"manifest.source.{field}", 80)
  876. if isinstance(source["snapshot_cursor"], bool) or not isinstance(source["snapshot_cursor"], int) or source["snapshot_cursor"] < 0:
  877. raise DomainReplicationError("manifest.source.snapshot_cursor is invalid")
  878. assessment = _closed_required(
  879. manifest["core_change_assessment"], "core_change_assessment",
  880. {"baseline_commit", "device_specific_changes", "generic_extensions", "extension_points_used"},
  881. {"baseline_commit", "device_specific_changes", "generic_extensions", "extension_points_used"},
  882. )
  883. device_changes = assessment.get("device_specific_changes")
  884. if not isinstance(device_changes, list):
  885. raise DomainReplicationError("device_specific_changes must be a list")
  886. if device_changes:
  887. raise DomainReplicationError("device-specific core changes must remain zero")
  888. generic_extensions = _bounded_list(assessment["generic_extensions"], "generic_extensions")
  889. if any(
  890. str(path).startswith(marker)
  891. for path in generic_extensions
  892. for marker in _DEVICE_PATH_MARKERS
  893. ):
  894. raise DomainReplicationError("generic extensions cannot target device-specific paths")
  895. reuse = _closed_required(
  896. manifest["third_domain_reuse"], "third_domain_reuse",
  897. {"reusable", "required_replacements"}, {"reusable", "required_replacements"},
  898. )
  899. if reuse.get("reusable") is not True:
  900. raise DomainReplicationError("third-domain reuse must be explicitly enabled")
  901. placeholders = reuse["required_replacements"]
  902. if not isinstance(placeholders, list) or len(placeholders) < 4:
  903. raise DomainReplicationError("third-domain replacement checklist is incomplete")
  904. return package_code, domain_code, schema_version
  905. def evaluate_replication_package(
  906. manifest: dict[str, Any],
  907. evidence: dict[str, Any],
  908. *,
  909. template: dict[str, Any],
  910. snapshot_rows: list[dict[str, str]],
  911. delta_rows: list[dict[str, str]],
  912. execution_evidence: dict[str, Any] | None = None,
  913. ) -> dict[str, Any]:
  914. """Evaluate one domain package and return a deterministic, row-free report."""
  915. manifest_schema_version = _mapping(manifest, "manifest").get("schema_version")
  916. if isinstance(manifest_schema_version, bool) or manifest_schema_version not in {1, 2}:
  917. raise DomainReplicationError("manifest.schema_version must be 1 or 2")
  918. template = _validate_template(template, schema_version=manifest_schema_version)
  919. package_code, domain_code, schema_version = _validate_manifest(manifest, template)
  920. incremental = collect_incremental_rows(
  921. snapshot_rows,
  922. delta_rows,
  923. _mapping(manifest.get("source"), "source"),
  924. )
  925. stage_reports = _validate_terminal_evidence(
  926. evidence, package_code, domain_code, template, incremental, schema_version=schema_version
  927. )
  928. bindings = _bounded_list(manifest["enterprise_bindings"], "enterprise_bindings", minimum=1, maximum=4)
  929. binding_statuses: dict[str, str] = {}
  930. for item in bindings:
  931. item = _closed_required(item, "enterprise_bindings.item", {"kind", "status"}, {"kind", "status"})
  932. kind = _string(item["kind"], "enterprise_bindings.kind")
  933. status = _string(item["status"], "enterprise_bindings.status")
  934. if status not in {"bound", "unbound"}:
  935. raise DomainReplicationError("enterprise binding status must be bound or unbound")
  936. if kind in binding_statuses:
  937. raise DomainReplicationError(f"duplicate enterprise binding: {kind}")
  938. binding_statuses[kind] = status
  939. missing_bindings = sorted(_REQUIRED_ENTERPRISE_BINDINGS - set(binding_statuses))
  940. if missing_bindings:
  941. raise DomainReplicationError(
  942. f"missing enterprise bindings: {','.join(missing_bindings)}"
  943. )
  944. unbound = sorted(
  945. kind for kind, status in binding_statuses.items() if status == "unbound"
  946. )
  947. assessment = manifest["core_change_assessment"]
  948. report: dict[str, Any] = {
  949. "schema_version": schema_version,
  950. "package_code": package_code,
  951. "status": "passed",
  952. "domain": {
  953. "code": domain_code,
  954. "name": _string(manifest["domain"]["name"], "domain.name"),
  955. "template_version": int(
  956. stage_reports["template_initialization"]["metrics"]["template_version"]
  957. ),
  958. },
  959. "incremental_collection": incremental,
  960. "stages": stage_reports,
  961. "device_specific_core_changes": 0,
  962. "generic_extension_count": len(assessment["generic_extensions"]),
  963. "third_domain_reusable": True,
  964. "enterprise_uat": {
  965. "status": "blocked_external" if unbound else "ready",
  966. "unbound_requirements": unbound,
  967. },
  968. }
  969. if schema_version == 2:
  970. if execution_evidence is None:
  971. raise DomainReplicationError("schema-v2 package requires execution evidence")
  972. report["execution_evidence"] = validate_execution_evidence(
  973. execution_evidence, domain_code=domain_code
  974. )
  975. elif execution_evidence is not None:
  976. raise DomainReplicationError("schema-v1 package cannot include execution evidence")
  977. report["report_sha256"] = _digest(report)
  978. return report