rules.py 24 KB

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