contracts.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. """Closed, deterministic contracts for AI-authored data processing assets."""
  2. from __future__ import annotations
  3. import copy
  4. import hashlib
  5. import json
  6. import re
  7. from typing import Any
  8. from app.core.common.identifiers import ensure_governance_uid
  9. SCHEMA_VERSION = "1.0"
  10. RULE_OPS = {
  11. "aggregate",
  12. "assert",
  13. "cast",
  14. "deduplicate",
  15. "derive",
  16. "fill_null",
  17. "filter",
  18. "lookup_join",
  19. "map_values",
  20. "mask",
  21. "normalize_text",
  22. "regex_replace",
  23. }
  24. COMPONENT_TYPES = {"standard.enforce", "rule.apply", "quality.check"}
  25. STAGES = {"extract", "normalize", "transform", "quality_gate", "write", "publish"}
  26. SEVERITIES = {"info", "warning", "error", "critical"}
  27. FAILURE_ACTIONS = {"reject", "quarantine", "warn", "fail"}
  28. NULL_POLICIES = {"explicit", "preserve", "reject"}
  29. IDEMPOTENCY_STRATEGIES = {
  30. "partition_replace",
  31. "upsert",
  32. "deduplication_key",
  33. }
  34. SECRET_KEY_NAMES = {
  35. "apikey",
  36. "authorization",
  37. "connectionstring",
  38. "credential",
  39. "credentials",
  40. "dsn",
  41. "password",
  42. "secret",
  43. "token",
  44. }
  45. RULE_ROOT_KEYS = {
  46. "schema_version",
  47. "rule_uid",
  48. "name",
  49. "description",
  50. "input_schema_ref",
  51. "output_schema_ref",
  52. "steps",
  53. "null_policy",
  54. "timezone",
  55. }
  56. RULE_STEP_KEYS = {
  57. "id",
  58. "op",
  59. "column",
  60. "target",
  61. "to",
  62. "expression",
  63. "pattern",
  64. "replacement",
  65. "value",
  66. "trim",
  67. "lowercase",
  68. "uppercase",
  69. "on_error",
  70. "on_failure",
  71. "severity",
  72. "keys",
  73. "keep",
  74. "order_by",
  75. "mapping",
  76. "group_by",
  77. "aggregations",
  78. "lookup",
  79. "policy",
  80. }
  81. STANDARD_ROOT_KEYS = {
  82. "schema_version",
  83. "standard_uid",
  84. "name",
  85. "description",
  86. "scope",
  87. "clauses",
  88. }
  89. STANDARD_SCOPE_KEYS = {"object_type", "schema_ref", "business_domain_uid"}
  90. STANDARD_CLAUSE_KEYS = {
  91. "id",
  92. "description",
  93. "severity",
  94. "rule_version_id",
  95. "exception_policy",
  96. }
  97. DATAFLOW_ROOT_KEYS = {
  98. "schema_version",
  99. "dataflow_uid",
  100. "name",
  101. "description",
  102. "input_schema_refs",
  103. "output_schema_ref",
  104. "components",
  105. "parameters",
  106. }
  107. COMPONENT_KEYS = {
  108. "id",
  109. "type",
  110. "rule_version_id",
  111. "standard_version_id",
  112. "stage",
  113. "order",
  114. "idempotency",
  115. }
  116. IDEMPOTENCY_KEYS = {"strategy", "key"}
  117. CANDIDATE_KEYS = {
  118. "schema_version",
  119. "candidate_type",
  120. "rule_spec",
  121. "standard_spec",
  122. "assumptions",
  123. "ambiguities",
  124. "confidence",
  125. "explanation",
  126. }
  127. RULE_SPEC_SCHEMA = {
  128. "$schema": "https://json-schema.org/draft/2020-12/schema",
  129. "$id": "https://dataops.local/schemas/rule-spec-1.0.json",
  130. "title": "DataOps RuleSpec",
  131. "type": "object",
  132. "additionalProperties": False,
  133. "required": sorted(RULE_ROOT_KEYS - {"description"}),
  134. "properties": {
  135. "schema_version": {"const": SCHEMA_VERSION},
  136. "rule_uid": {"type": "string", "format": "uuid"},
  137. "name": {"type": "string", "minLength": 1, "maxLength": 200},
  138. "description": {"type": "string", "maxLength": 2000},
  139. "input_schema_ref": {"type": "string", "minLength": 1, "maxLength": 500},
  140. "output_schema_ref": {"type": "string", "minLength": 1, "maxLength": 500},
  141. "steps": {"type": "array", "minItems": 1, "maxItems": 200},
  142. "null_policy": {"enum": sorted(NULL_POLICIES)},
  143. "timezone": {"type": "string", "minLength": 1, "maxLength": 100},
  144. },
  145. }
  146. STANDARD_SPEC_SCHEMA = {
  147. "$schema": "https://json-schema.org/draft/2020-12/schema",
  148. "$id": "https://dataops.local/schemas/data-standard-spec-1.0.json",
  149. "title": "DataOps DataStandardSpec",
  150. "type": "object",
  151. "additionalProperties": False,
  152. "required": ["schema_version", "standard_uid", "name", "scope", "clauses"],
  153. }
  154. DATAFLOW_SPEC_SCHEMA = {
  155. "$schema": "https://json-schema.org/draft/2020-12/schema",
  156. "$id": "https://dataops.local/schemas/dataflow-production-line-1.0.json",
  157. "title": "DataOps DataFlow Production Line",
  158. "type": "object",
  159. "additionalProperties": False,
  160. "required": [
  161. "schema_version",
  162. "dataflow_uid",
  163. "name",
  164. "input_schema_refs",
  165. "output_schema_ref",
  166. "components",
  167. "parameters",
  168. ],
  169. }
  170. RULE_CANDIDATE_SCHEMA = {
  171. "$schema": "https://json-schema.org/draft/2020-12/schema",
  172. "$id": "https://dataops.local/schemas/rule-candidate-1.0.json",
  173. "title": "DataOps AI Rule Candidate",
  174. "type": "object",
  175. "additionalProperties": False,
  176. "required": sorted(CANDIDATE_KEYS),
  177. }
  178. def _closed_object(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
  179. if not isinstance(value, dict):
  180. raise ValueError(f"{label} must be an object")
  181. unknown = sorted(set(value) - allowed)
  182. if unknown:
  183. raise ValueError(
  184. f"{label} contains unsupported fields: {', '.join(unknown)}"
  185. )
  186. return value
  187. def _required_string(value: Any, label: str, maximum: int = 500) -> str:
  188. if not isinstance(value, str) or not value.strip():
  189. raise ValueError(f"{label} is required")
  190. normalized = value.strip()
  191. if len(normalized) > maximum:
  192. raise ValueError(f"{label} exceeds {maximum} characters")
  193. return normalized
  194. def _optional_string(value: Any, label: str, maximum: int = 2000) -> str:
  195. if not isinstance(value, str):
  196. raise ValueError(f"{label} must be a string")
  197. normalized = value.strip()
  198. if len(normalized) > maximum:
  199. raise ValueError(f"{label} exceeds {maximum} characters")
  200. return normalized
  201. def _uid(value: Any, label: str) -> str:
  202. try:
  203. return ensure_governance_uid({"uid": str(value)})
  204. except ValueError as exc:
  205. raise ValueError(f"{label} must be a valid UUIDv7") from exc
  206. def _identifier(value: Any, label: str) -> str:
  207. result = _required_string(value, label, 100)
  208. if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,99}", result):
  209. raise ValueError(f"{label} contains unsupported characters")
  210. return result
  211. def _normalized_secret_key(value: Any) -> str:
  212. return re.sub(r"[^a-z0-9]", "", str(value).lower())
  213. def _reject_secret_material(value: Any, path: str = "$") -> None:
  214. if isinstance(value, dict):
  215. for key, item in value.items():
  216. if _normalized_secret_key(key) in SECRET_KEY_NAMES:
  217. raise ValueError(f"secret material is not allowed at {path}.{key}")
  218. _reject_secret_material(item, f"{path}.{key}")
  219. elif isinstance(value, list):
  220. for index, item in enumerate(value):
  221. _reject_secret_material(item, f"{path}[{index}]")
  222. def _bounded_strings(
  223. value: Any,
  224. label: str,
  225. *,
  226. maximum_items: int = 100,
  227. maximum_length: int = 1000,
  228. ) -> list[str]:
  229. if not isinstance(value, list) or len(value) > maximum_items:
  230. raise ValueError(f"{label} must be a bounded array")
  231. return [
  232. _required_string(item, f"{label} item", maximum_length) for item in value
  233. ]
  234. def _canonical_hash(value: dict[str, Any]) -> str:
  235. canonical = json.dumps(
  236. value,
  237. sort_keys=True,
  238. separators=(",", ":"),
  239. ensure_ascii=False,
  240. )
  241. return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  242. def _validate_rule_step(value: Any) -> dict[str, Any]:
  243. step = copy.deepcopy(_closed_object(value, RULE_STEP_KEYS, "rule step"))
  244. step["id"] = _identifier(step.get("id"), "rule step id")
  245. operation = _required_string(step.get("op"), "rule step operation", 100)
  246. if operation not in RULE_OPS:
  247. raise ValueError(f"unsupported rule operation: {operation}")
  248. step["op"] = operation
  249. for key in ("column", "target", "to", "expression", "pattern", "replacement"):
  250. if key in step:
  251. step[key] = _required_string(
  252. step[key], f"rule step {key}", 2000
  253. )
  254. for key in ("keys", "order_by", "group_by"):
  255. if key in step:
  256. step[key] = _bounded_strings(
  257. step[key], f"rule step {key}", maximum_length=200
  258. )
  259. for key in ("mapping", "aggregations", "lookup"):
  260. if key in step and not isinstance(step[key], dict):
  261. raise ValueError(f"rule step {key} must be an object")
  262. for key in ("trim", "lowercase", "uppercase"):
  263. if key in step and not isinstance(step[key], bool):
  264. raise ValueError(f"rule step {key} must be a boolean")
  265. if "severity" in step and step["severity"] not in SEVERITIES:
  266. raise ValueError("unsupported rule step severity")
  267. for key in ("on_error", "on_failure"):
  268. if key in step and step[key] not in FAILURE_ACTIONS:
  269. raise ValueError(f"unsupported rule step {key}")
  270. if "keep" in step and step["keep"] not in {"first", "last"}:
  271. raise ValueError("deduplicate keep must be first or last")
  272. if operation == "assert":
  273. _required_string(step.get("expression"), "assert expression", 2000)
  274. if step.get("on_failure") not in FAILURE_ACTIONS:
  275. raise ValueError("assert on_failure is required")
  276. if operation == "cast":
  277. _required_string(step.get("column"), "cast column", 200)
  278. _required_string(step.get("to"), "cast target type", 100)
  279. if operation in {"normalize_text", "regex_replace", "fill_null"}:
  280. _required_string(step.get("column"), f"{operation} column", 200)
  281. if operation == "deduplicate" and not step.get("keys"):
  282. raise ValueError("deduplicate keys are required")
  283. if operation == "mask":
  284. _required_string(step.get("policy"), "mask policy", 200)
  285. _reject_secret_material(step)
  286. return step
  287. def validate_rule_spec(value: Any) -> dict[str, Any]:
  288. spec = copy.deepcopy(_closed_object(value, RULE_ROOT_KEYS, "rule spec"))
  289. if spec.get("schema_version") != SCHEMA_VERSION:
  290. raise ValueError(f"rule spec schema_version must be {SCHEMA_VERSION}")
  291. spec["rule_uid"] = _uid(spec.get("rule_uid"), "rule_uid")
  292. spec["name"] = _required_string(spec.get("name"), "rule name", 200)
  293. if "description" in spec:
  294. spec["description"] = _optional_string(
  295. spec["description"], "rule description"
  296. )
  297. spec["input_schema_ref"] = _required_string(
  298. spec.get("input_schema_ref"), "input_schema_ref"
  299. )
  300. spec["output_schema_ref"] = _required_string(
  301. spec.get("output_schema_ref"), "output_schema_ref"
  302. )
  303. raw_steps = spec.get("steps")
  304. if not isinstance(raw_steps, list) or not raw_steps or len(raw_steps) > 200:
  305. raise ValueError("rule steps must be a non-empty bounded array")
  306. spec["steps"] = [_validate_rule_step(item) for item in raw_steps]
  307. ids = [item["id"] for item in spec["steps"]]
  308. if len(ids) != len(set(ids)):
  309. raise ValueError("rule step ids must be unique")
  310. if spec.get("null_policy") not in NULL_POLICIES:
  311. raise ValueError("unsupported null_policy")
  312. spec["timezone"] = _required_string(
  313. spec.get("timezone"), "rule timezone", 100
  314. )
  315. _reject_secret_material(spec)
  316. return spec
  317. def rule_spec_hash(value: Any) -> str:
  318. return _canonical_hash(validate_rule_spec(value))
  319. def validate_standard_spec(value: Any) -> dict[str, Any]:
  320. spec = copy.deepcopy(
  321. _closed_object(value, STANDARD_ROOT_KEYS, "standard spec")
  322. )
  323. if spec.get("schema_version") != SCHEMA_VERSION:
  324. raise ValueError(f"standard spec schema_version must be {SCHEMA_VERSION}")
  325. spec["standard_uid"] = _uid(spec.get("standard_uid"), "standard_uid")
  326. spec["name"] = _required_string(spec.get("name"), "standard name", 200)
  327. if "description" in spec:
  328. spec["description"] = _optional_string(
  329. spec["description"], "standard description"
  330. )
  331. scope = copy.deepcopy(
  332. _closed_object(spec.get("scope"), STANDARD_SCOPE_KEYS, "standard scope")
  333. )
  334. scope["object_type"] = _required_string(
  335. scope.get("object_type"), "standard scope object_type", 100
  336. )
  337. scope["schema_ref"] = _required_string(
  338. scope.get("schema_ref"), "standard scope schema_ref"
  339. )
  340. if "business_domain_uid" in scope:
  341. scope["business_domain_uid"] = _uid(
  342. scope["business_domain_uid"], "standard scope business_domain_uid"
  343. )
  344. spec["scope"] = scope
  345. raw_clauses = spec.get("clauses")
  346. if (
  347. not isinstance(raw_clauses, list)
  348. or not raw_clauses
  349. or len(raw_clauses) > 200
  350. ):
  351. raise ValueError("standard clauses must be a non-empty bounded array")
  352. clauses = []
  353. for raw_clause in raw_clauses:
  354. clause = copy.deepcopy(
  355. _closed_object(
  356. raw_clause, STANDARD_CLAUSE_KEYS, "standard clause"
  357. )
  358. )
  359. clause["id"] = _identifier(clause.get("id"), "standard clause id")
  360. clause["description"] = _required_string(
  361. clause.get("description"), "standard clause description", 2000
  362. )
  363. if clause.get("severity") not in SEVERITIES:
  364. raise ValueError("unsupported standard clause severity")
  365. clause["rule_version_id"] = _uid(
  366. clause.get("rule_version_id"), "standard clause rule_version_id"
  367. )
  368. if clause.get("exception_policy") not in FAILURE_ACTIONS:
  369. raise ValueError("unsupported standard clause exception_policy")
  370. clauses.append(clause)
  371. clause_ids = [item["id"] for item in clauses]
  372. if len(clause_ids) != len(set(clause_ids)):
  373. raise ValueError("standard clause ids must be unique")
  374. spec["clauses"] = clauses
  375. _reject_secret_material(spec)
  376. return spec
  377. def standard_spec_hash(value: Any) -> str:
  378. return _canonical_hash(validate_standard_spec(value))
  379. def _validate_idempotency(value: Any) -> dict[str, Any]:
  380. item = copy.deepcopy(
  381. _closed_object(value, IDEMPOTENCY_KEYS, "component idempotency")
  382. )
  383. if item.get("strategy") not in IDEMPOTENCY_STRATEGIES:
  384. raise ValueError("unsupported component idempotency strategy")
  385. item["key"] = _required_string(
  386. item.get("key"), "component idempotency key", 500
  387. )
  388. return item
  389. def validate_dataflow_spec(value: Any) -> dict[str, Any]:
  390. spec = copy.deepcopy(
  391. _closed_object(value, DATAFLOW_ROOT_KEYS, "dataflow spec")
  392. )
  393. if spec.get("schema_version") != SCHEMA_VERSION:
  394. raise ValueError(f"dataflow spec schema_version must be {SCHEMA_VERSION}")
  395. spec["dataflow_uid"] = _uid(spec.get("dataflow_uid"), "dataflow_uid")
  396. spec["name"] = _required_string(spec.get("name"), "dataflow name", 200)
  397. if "description" in spec:
  398. spec["description"] = _optional_string(
  399. spec["description"], "dataflow description"
  400. )
  401. spec["input_schema_refs"] = _bounded_strings(
  402. spec.get("input_schema_refs"),
  403. "input_schema_refs",
  404. maximum_items=50,
  405. maximum_length=500,
  406. )
  407. if not spec["input_schema_refs"]:
  408. raise ValueError("input_schema_refs must not be empty")
  409. spec["output_schema_ref"] = _required_string(
  410. spec.get("output_schema_ref"), "output_schema_ref"
  411. )
  412. raw_components = spec.get("components")
  413. if (
  414. not isinstance(raw_components, list)
  415. or not raw_components
  416. or len(raw_components) > 500
  417. ):
  418. raise ValueError("dataflow components must be a non-empty bounded array")
  419. components = []
  420. for raw_component in raw_components:
  421. component = copy.deepcopy(
  422. _closed_object(
  423. raw_component, COMPONENT_KEYS, "dataflow component"
  424. )
  425. )
  426. component["id"] = _identifier(
  427. component.get("id"), "dataflow component id"
  428. )
  429. component_type = component.get("type")
  430. if component_type not in COMPONENT_TYPES:
  431. raise ValueError("unsupported dataflow component type")
  432. component["type"] = component_type
  433. if component.get("stage") not in STAGES:
  434. raise ValueError("unsupported dataflow component stage")
  435. order = component.get("order")
  436. if (
  437. isinstance(order, bool)
  438. or not isinstance(order, int)
  439. or order < 0
  440. or order > 1_000_000
  441. ):
  442. raise ValueError("dataflow component order must be a bounded integer")
  443. if component_type == "standard.enforce":
  444. component["standard_version_id"] = _uid(
  445. component.get("standard_version_id"),
  446. "component standard_version_id",
  447. )
  448. if "rule_version_id" in component or "idempotency" in component:
  449. raise ValueError(
  450. "standard.enforce cannot define rule or idempotency"
  451. )
  452. else:
  453. component["rule_version_id"] = _uid(
  454. component.get("rule_version_id"),
  455. "component rule_version_id",
  456. )
  457. if "standard_version_id" in component:
  458. raise ValueError(
  459. "rule component cannot define standard_version_id"
  460. )
  461. if component_type == "rule.apply":
  462. component["idempotency"] = _validate_idempotency(
  463. component.get("idempotency")
  464. )
  465. elif "idempotency" in component:
  466. raise ValueError("quality.check cannot define idempotency")
  467. components.append(component)
  468. component_ids = [item["id"] for item in components]
  469. if len(component_ids) != len(set(component_ids)):
  470. raise ValueError("dataflow component ids must be unique")
  471. spec["components"] = sorted(
  472. components, key=lambda item: (item["order"], item["id"])
  473. )
  474. parameters = spec.get("parameters")
  475. if not isinstance(parameters, dict) or len(parameters) > 100:
  476. raise ValueError("dataflow parameters must be a bounded object")
  477. spec["parameters"] = parameters
  478. _reject_secret_material(spec)
  479. return spec
  480. def dataflow_spec_hash(value: Any) -> str:
  481. return _canonical_hash(validate_dataflow_spec(value))
  482. def validate_rule_candidate(value: Any) -> dict[str, Any]:
  483. candidate = copy.deepcopy(
  484. _closed_object(value, CANDIDATE_KEYS, "rule candidate")
  485. )
  486. missing = sorted(CANDIDATE_KEYS - set(candidate))
  487. if missing:
  488. raise ValueError(
  489. f"rule candidate is missing fields: {', '.join(missing)}"
  490. )
  491. if candidate.get("schema_version") != SCHEMA_VERSION:
  492. raise ValueError(
  493. f"rule candidate schema_version must be {SCHEMA_VERSION}"
  494. )
  495. candidate_type = candidate.get("candidate_type")
  496. if candidate_type not in {"rule", "standard"}:
  497. raise ValueError("unsupported rule candidate_type")
  498. if candidate_type == "rule":
  499. candidate["rule_spec"] = validate_rule_spec(
  500. candidate.get("rule_spec")
  501. )
  502. if candidate.get("standard_spec") is not None:
  503. raise ValueError("rule candidate cannot contain standard_spec")
  504. else:
  505. candidate["standard_spec"] = validate_standard_spec(
  506. candidate.get("standard_spec")
  507. )
  508. if candidate.get("rule_spec") is not None:
  509. candidate["rule_spec"] = validate_rule_spec(
  510. candidate["rule_spec"]
  511. )
  512. candidate["assumptions"] = _bounded_strings(
  513. candidate.get("assumptions"), "candidate assumptions", maximum_items=50
  514. )
  515. candidate["ambiguities"] = _bounded_strings(
  516. candidate.get("ambiguities"), "candidate ambiguities", maximum_items=50
  517. )
  518. confidence = candidate.get("confidence")
  519. if (
  520. isinstance(confidence, bool)
  521. or not isinstance(confidence, (int, float))
  522. or confidence < 0
  523. or confidence > 1
  524. ):
  525. raise ValueError("candidate confidence must be between 0 and 1")
  526. candidate["confidence"] = float(confidence)
  527. candidate["explanation"] = _required_string(
  528. candidate.get("explanation"), "candidate explanation", 2000
  529. )
  530. _reject_secret_material(candidate)
  531. return candidate