rules.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. """Load immutable rule plans and dispatch them through allowlisted adapters."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import re
  6. import time
  7. from asyncio import CancelledError
  8. from collections.abc import Mapping
  9. from typing import Any
  10. from sqlalchemy import text
  11. from app.core.common.identifiers import ensure_governance_uid
  12. from app.core.data_rules.compilers.polars import (
  13. COMPILER_VERSION as POLARS_COMPILER_VERSION,
  14. )
  15. from app.core.data_rules.compilers.polars import (
  16. validate_bound_polars_plan,
  17. )
  18. from app.core.data_rules.compilers.sql import (
  19. COMPILER_VERSION as SQL_COMPILER_VERSION,
  20. )
  21. from app.core.data_rules.compilers.sql import (
  22. validate_bound_sql_plan,
  23. )
  24. from app.runner.nodes import NodeExecutionError
  25. CONFIG_KEYS = {
  26. "component_binding_id",
  27. "rule_version_id",
  28. "execution_plan_hash",
  29. "provenance",
  30. }
  31. IDEMPOTENCY_STRATEGIES = {
  32. "partition_replace",
  33. "upsert",
  34. "deduplication_key",
  35. }
  36. def _canonical_hash(value: Any) -> str:
  37. try:
  38. canonical = json.dumps(
  39. value,
  40. sort_keys=True,
  41. separators=(",", ":"),
  42. ensure_ascii=False,
  43. )
  44. except (TypeError, ValueError) as exc:
  45. raise NodeExecutionError(
  46. "published rule plan is not JSON serializable"
  47. ) from exc
  48. return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  49. def _uid(value: Any, label: str) -> str:
  50. try:
  51. return ensure_governance_uid({"uid": str(value)})
  52. except ValueError as exc:
  53. raise NodeExecutionError(f"{label} is invalid") from exc
  54. class PostgresRulePlanRepository:
  55. """Read one plan through all immutable binding and version constraints."""
  56. def __init__(self, engine):
  57. self.engine = engine
  58. def load(
  59. self,
  60. *,
  61. component_binding_id: str,
  62. rule_version_id: str,
  63. plan_hash: str,
  64. ) -> dict[str, Any] | None:
  65. statement = text(
  66. """
  67. SELECT
  68. p.component_binding_id::text AS component_binding_id,
  69. b.rule_version_id::text AS rule_version_id,
  70. p.backend,
  71. p.compiler_version,
  72. p.plan,
  73. p.plan_hash,
  74. p.schema_hashes,
  75. p.status AS plan_status,
  76. r.status AS rule_status,
  77. r.spec_hash AS canonical_rule_spec_hash,
  78. ins.id::text AS canonical_input_schema_snapshot_id,
  79. ins.schema_hash AS canonical_input_schema_hash,
  80. outs.id::text AS canonical_output_schema_snapshot_id,
  81. outs.schema_hash AS canonical_output_schema_hash,
  82. b.component_kind,
  83. b.idempotency AS binding_idempotency
  84. FROM public.rule_execution_plans p
  85. JOIN public.dataflow_component_bindings b
  86. ON b.id = p.component_binding_id
  87. JOIN public.data_rule_versions r
  88. ON r.id = b.rule_version_id
  89. LEFT JOIN public.dataflow_dataset_bindings ib
  90. ON ib.id = CAST(p.plan->>'input_binding_id' AS uuid)
  91. LEFT JOIN public.data_schema_snapshots ins
  92. ON ins.id = ib.schema_snapshot_id
  93. LEFT JOIN public.dataflow_dataset_bindings ob
  94. ON ob.id = CAST(p.plan->>'output_binding_id' AS uuid)
  95. LEFT JOIN public.data_schema_snapshots outs
  96. ON outs.id = ob.schema_snapshot_id
  97. WHERE p.component_binding_id = CAST(:component_binding_id AS uuid)
  98. AND b.rule_version_id = CAST(:rule_version_id AS uuid)
  99. AND p.plan_hash = :plan_hash
  100. """
  101. )
  102. with self.engine.connect() as connection:
  103. row = connection.execute(
  104. statement,
  105. {
  106. "component_binding_id": component_binding_id,
  107. "rule_version_id": rule_version_id,
  108. "plan_hash": plan_hash,
  109. },
  110. ).mappings().one_or_none()
  111. return dict(row) if row is not None else None
  112. class RulePlanExecutor:
  113. """Fail closed unless the exact published plan is still executable."""
  114. def __init__(
  115. self,
  116. repository,
  117. *,
  118. adapters: Mapping[str, Any],
  119. evidence_writer=None,
  120. ):
  121. self.repository = repository
  122. self.adapters = dict(adapters)
  123. self.evidence_writer = evidence_writer
  124. @staticmethod
  125. def _redacted_sample(value: Any) -> list[dict[str, Any]]:
  126. if not isinstance(value, list):
  127. return []
  128. sample = []
  129. for row in value[:100]:
  130. if not isinstance(row, dict):
  131. continue
  132. sample.append(
  133. {
  134. str(key): (
  135. None if item is None else "[REDACTED]"
  136. )
  137. for key, item in sorted(row.items())
  138. }
  139. )
  140. return sample
  141. def _start_evidence(
  142. self,
  143. *,
  144. component_binding_id: str,
  145. rule_version_id: str,
  146. plan_hash: str,
  147. execution_context: Mapping[str, Any],
  148. ):
  149. if self.evidence_writer is None:
  150. return None, None
  151. correlation_id = execution_context.get("correlation_id")
  152. dataflow_uid = execution_context.get("dataflow_uid")
  153. workflow_version = execution_context.get("workflow_version")
  154. node_id = execution_context.get("node_id")
  155. if (
  156. not correlation_id
  157. or not dataflow_uid
  158. or isinstance(workflow_version, bool)
  159. or not isinstance(workflow_version, int)
  160. or workflow_version < 1
  161. or not isinstance(node_id, str)
  162. or not node_id
  163. ):
  164. raise NodeExecutionError(
  165. "trusted rule evidence context is incomplete"
  166. )
  167. try:
  168. rule_run_id = self.evidence_writer.start(
  169. component_binding_id=component_binding_id,
  170. rule_version_id=rule_version_id,
  171. plan_hash=plan_hash,
  172. correlation_id=correlation_id,
  173. dataflow_uid=dataflow_uid,
  174. workflow_version=workflow_version,
  175. node_id=node_id,
  176. )
  177. replay = self.evidence_writer.replay(rule_run_id)
  178. except Exception as exc:
  179. raise NodeExecutionError(
  180. "rule execution evidence is unavailable"
  181. ) from exc
  182. return rule_run_id, replay
  183. def _finish_evidence(self, rule_run_id, result):
  184. if rule_run_id is None:
  185. return
  186. try:
  187. self.evidence_writer.finish(rule_run_id, result)
  188. except Exception as exc:
  189. raise NodeExecutionError(
  190. "rule execution evidence commit outcome is unknown",
  191. commit_outcome="unknown",
  192. ) from exc
  193. def execute(
  194. self,
  195. node,
  196. parameters,
  197. *,
  198. write_authorized=False,
  199. **execution_context,
  200. ):
  201. if node.get("type") not in {"rule.apply", "quality.check"}:
  202. raise NodeExecutionError("unsupported governed rule node")
  203. config = node.get("config")
  204. if not isinstance(config, dict) or set(config) - CONFIG_KEYS:
  205. raise NodeExecutionError(
  206. "governed rule node contains inline or unsupported fields"
  207. )
  208. component_binding_id = _uid(
  209. config.get("component_binding_id"),
  210. "component_binding_id",
  211. )
  212. rule_version_id = _uid(
  213. config.get("rule_version_id"),
  214. "rule_version_id",
  215. )
  216. plan_hash = str(config.get("execution_plan_hash") or "")
  217. if not re.fullmatch(r"[0-9a-f]{64}", plan_hash):
  218. raise NodeExecutionError("execution plan hash is invalid")
  219. if node.get("type") == "rule.apply":
  220. idempotency = node.get("idempotency")
  221. if (
  222. node.get("purpose") != "write"
  223. or not write_authorized
  224. or not isinstance(idempotency, dict)
  225. or idempotency.get("strategy")
  226. not in IDEMPOTENCY_STRATEGIES
  227. or not str(idempotency.get("key") or "").strip()
  228. ):
  229. raise NodeExecutionError(
  230. "governed write authorization and idempotency are required"
  231. )
  232. elif node.get("purpose") != "read":
  233. raise NodeExecutionError("quality check must be read only")
  234. record = self.repository.load(
  235. component_binding_id=component_binding_id,
  236. rule_version_id=rule_version_id,
  237. plan_hash=plan_hash,
  238. )
  239. if not isinstance(record, dict):
  240. raise NodeExecutionError("published rule plan was not found")
  241. if (
  242. record.get("component_binding_id") != component_binding_id
  243. or record.get("rule_version_id") != rule_version_id
  244. or record.get("plan_hash") != plan_hash
  245. or record.get("plan_status") != "published"
  246. or record.get("rule_status") != "published"
  247. or _canonical_hash(record.get("plan")) != plan_hash
  248. ):
  249. raise NodeExecutionError("published rule plan is not executable")
  250. if node.get("type") == "rule.apply" and (
  251. record.get("component_kind") != "rule.apply"
  252. or record.get("binding_idempotency") != node.get("idempotency")
  253. ):
  254. raise NodeExecutionError(
  255. "governed rule idempotency does not match its binding"
  256. )
  257. backend = record.get("backend")
  258. if backend == "sql_pushdown":
  259. try:
  260. plan = validate_bound_sql_plan(record.get("plan"))
  261. except ValueError as exc:
  262. raise NodeExecutionError(
  263. "published rule plan is not executable"
  264. ) from exc
  265. expected_schema_hashes = {
  266. "rule_spec_hash": plan["rule_spec_hash"],
  267. "input_schema_snapshot_id": plan[
  268. "input_schema_snapshot_id"
  269. ],
  270. "input_schema_hash": plan["input_schema_hash"],
  271. "output_schema_snapshot_id": plan[
  272. "output_schema_snapshot_id"
  273. ],
  274. "output_schema_hash": plan["output_schema_hash"],
  275. }
  276. if (
  277. record.get("compiler_version") != SQL_COMPILER_VERSION
  278. or plan["compiler_version"] != SQL_COMPILER_VERSION
  279. or record.get("schema_hashes") != expected_schema_hashes
  280. or record.get("canonical_rule_spec_hash")
  281. != plan["rule_spec_hash"]
  282. or record.get("canonical_input_schema_snapshot_id")
  283. != plan["input_schema_snapshot_id"]
  284. or record.get("canonical_input_schema_hash")
  285. != plan["input_schema_hash"]
  286. or record.get("canonical_output_schema_snapshot_id")
  287. != plan["output_schema_snapshot_id"]
  288. or record.get("canonical_output_schema_hash")
  289. != plan["output_schema_hash"]
  290. ):
  291. raise NodeExecutionError(
  292. "published rule plan canonical attestation does not match"
  293. )
  294. elif backend == "polars_batch":
  295. try:
  296. plan = validate_bound_polars_plan(record.get("plan"))
  297. except ValueError as exc:
  298. raise NodeExecutionError(
  299. "published rule plan is not executable"
  300. ) from exc
  301. expected_schema_hashes = {
  302. "rule_spec_hash": plan["rule_spec_hash"],
  303. "input_schema_snapshot_id": plan[
  304. "input_schema_snapshot_id"
  305. ],
  306. "input_schema_hash": plan["input_schema_hash"],
  307. "output_schema_snapshot_id": plan[
  308. "output_schema_snapshot_id"
  309. ],
  310. "output_schema_hash": plan["output_schema_hash"],
  311. }
  312. if (
  313. record.get("compiler_version") != POLARS_COMPILER_VERSION
  314. or plan["compiler_version"] != POLARS_COMPILER_VERSION
  315. or record.get("schema_hashes") != expected_schema_hashes
  316. or record.get("canonical_rule_spec_hash")
  317. != plan["rule_spec_hash"]
  318. or record.get("canonical_input_schema_snapshot_id")
  319. != plan["input_schema_snapshot_id"]
  320. or record.get("canonical_input_schema_hash")
  321. != plan["input_schema_hash"]
  322. or record.get("canonical_output_schema_snapshot_id")
  323. != plan["output_schema_snapshot_id"]
  324. or record.get("canonical_output_schema_hash")
  325. != plan["output_schema_hash"]
  326. ):
  327. raise NodeExecutionError(
  328. "published rule plan canonical attestation does not match"
  329. )
  330. adapter = self.adapters.get(backend)
  331. if adapter is None or not callable(getattr(adapter, "execute", None)):
  332. raise NodeExecutionError("rule plan backend is not registered")
  333. adapter_parameters = parameters
  334. if backend == "sql_pushdown" and parameters not in ({}, None):
  335. expected = (
  336. "dataops-staging://"
  337. f"{execution_context.get('correlation_id')}/"
  338. f"{plan['input_binding_id']}"
  339. )
  340. if (
  341. not isinstance(parameters, dict)
  342. or set(parameters) != {"input_artifact"}
  343. or parameters["input_artifact"] != expected
  344. ):
  345. raise NodeExecutionError(
  346. "SQL rule handoff is not a canonical staging binding"
  347. )
  348. adapter_parameters = {}
  349. rule_run_id, replay = self._start_evidence(
  350. component_binding_id=component_binding_id,
  351. rule_version_id=rule_version_id,
  352. plan_hash=plan_hash,
  353. execution_context=execution_context,
  354. )
  355. if isinstance(replay, dict):
  356. status = replay.get("status")
  357. if status == "success":
  358. return {
  359. key: value
  360. for key, value in replay.items()
  361. if key != "status"
  362. }
  363. if status in {"failed", "unknown", "cancelled"}:
  364. raise NodeExecutionError(
  365. "rule execution was already finalized",
  366. commit_outcome=str(
  367. replay.get("commit_outcome")
  368. or "not_applicable"
  369. ),
  370. )
  371. raise NodeExecutionError("rule execution is already in progress")
  372. adapter_context = {}
  373. if backend == "polars_batch":
  374. adapter_context["correlation_id"] = execution_context.get(
  375. "correlation_id"
  376. )
  377. started = time.monotonic()
  378. try:
  379. result = adapter.execute(
  380. plan=record["plan"],
  381. node=node,
  382. parameters=adapter_parameters,
  383. write_authorized=write_authorized,
  384. **adapter_context,
  385. )
  386. except CancelledError:
  387. self._finish_evidence(
  388. rule_run_id,
  389. {
  390. "status": "cancelled",
  391. "commit_outcome": "not_applicable",
  392. "timings": {
  393. "duration_ms": int(
  394. (time.monotonic() - started) * 1000
  395. )
  396. },
  397. },
  398. )
  399. raise
  400. except NodeExecutionError as exc:
  401. self._finish_evidence(
  402. rule_run_id,
  403. {
  404. "status": (
  405. "unknown"
  406. if exc.commit_outcome == "unknown"
  407. else "failed"
  408. ),
  409. "commit_outcome": exc.commit_outcome,
  410. "timings": {
  411. "duration_ms": int(
  412. (time.monotonic() - started) * 1000
  413. )
  414. },
  415. },
  416. )
  417. raise
  418. except Exception as exc:
  419. self._finish_evidence(
  420. rule_run_id,
  421. {
  422. "status": "failed",
  423. "commit_outcome": "not_committed",
  424. "timings": {
  425. "duration_ms": int(
  426. (time.monotonic() - started) * 1000
  427. )
  428. },
  429. },
  430. )
  431. raise NodeExecutionError(
  432. "rule plan execution failed",
  433. commit_outcome="not_committed",
  434. ) from exc
  435. if not isinstance(result, dict):
  436. error = NodeExecutionError(
  437. "rule plan result must be an object"
  438. )
  439. self._finish_evidence(
  440. rule_run_id,
  441. {
  442. "status": "failed",
  443. "commit_outcome": error.commit_outcome,
  444. "timings": {
  445. "duration_ms": int(
  446. (time.monotonic() - started) * 1000
  447. )
  448. },
  449. },
  450. )
  451. raise error
  452. try:
  453. sample = self._redacted_sample(
  454. result.pop("_violation_sample", None)
  455. )
  456. public_result = {
  457. **result,
  458. "component_binding_id": component_binding_id,
  459. "rule_version_id": rule_version_id,
  460. "execution_plan_hash": plan_hash,
  461. }
  462. for metric in (
  463. "rows_in",
  464. "rows_out",
  465. "rows_rejected",
  466. "rows_quarantined",
  467. ):
  468. value = public_result.get(metric, 0)
  469. if isinstance(value, bool):
  470. raise ValueError("boolean metric")
  471. public_result[metric] = max(0, int(value))
  472. artifact_ref = public_result.get("artifact_ref")
  473. if isinstance(artifact_ref, str):
  474. public_result["output_artifact"] = artifact_ref
  475. elif backend == "sql_pushdown":
  476. public_result["output_artifact"] = (
  477. "dataops-staging://"
  478. f"{execution_context.get('correlation_id')}/"
  479. f"{plan['output_binding_id']}"
  480. )
  481. except (TypeError, ValueError) as exc:
  482. self._finish_evidence(
  483. rule_run_id,
  484. {
  485. "status": "failed",
  486. "commit_outcome": "not_committed",
  487. "timings": {
  488. "duration_ms": int(
  489. (time.monotonic() - started) * 1000
  490. )
  491. },
  492. },
  493. )
  494. raise NodeExecutionError(
  495. "rule plan result is invalid",
  496. commit_outcome="not_committed",
  497. ) from exc
  498. evidence_result = {
  499. "status": "success",
  500. "rows_in": max(0, int(public_result.get("rows_in", 0))),
  501. "rows_out": max(0, int(public_result.get("rows_out", 0))),
  502. "rows_rejected": max(
  503. 0, int(public_result.get("rows_rejected", 0))
  504. ),
  505. "rows_quarantined": max(
  506. 0, int(public_result.get("rows_quarantined", 0))
  507. ),
  508. "commit_outcome": str(
  509. public_result.get("commit_outcome", "not_applicable")
  510. ),
  511. "timings": {
  512. "duration_ms": int(
  513. (time.monotonic() - started) * 1000
  514. )
  515. },
  516. "public_result": public_result,
  517. }
  518. if sample:
  519. evidence_result.update(
  520. {
  521. "violation_sample": sample,
  522. "sample_count": len(sample),
  523. "redaction_policy": (
  524. "rule-violation-default-v1"
  525. ),
  526. }
  527. )
  528. self._finish_evidence(rule_run_id, evidence_result)
  529. return public_result