rules.py 27 KB

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