rule_evidence.py 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181
  1. """Transactional, server-attested evidence for governed rule execution."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import os
  6. import re
  7. import tempfile
  8. import uuid
  9. from contextlib import suppress
  10. from typing import Any
  11. import polars as pl
  12. from sqlalchemy import text
  13. from app.core.common.identifiers import (
  14. ensure_governance_uid,
  15. new_governance_uid,
  16. )
  17. _DIGEST = re.compile(r"^[0-9a-f]{64}$")
  18. _FINAL_STATUSES = {"success", "failed", "unknown", "cancelled"}
  19. _COMMIT_OUTCOMES = {
  20. "not_applicable",
  21. "not_committed",
  22. "committed",
  23. "unknown",
  24. }
  25. _FINISH_KEYS = {
  26. "status",
  27. "rows_in",
  28. "rows_out",
  29. "rows_rejected",
  30. "rows_quarantined",
  31. "commit_outcome",
  32. "timings",
  33. "public_result",
  34. "violation_sample",
  35. "sample_count",
  36. "redaction_policy",
  37. }
  38. _PUBLIC_RESULT_KEYS = {
  39. "affected_rows",
  40. "artifact_ref",
  41. "commit_outcome",
  42. "component_binding_id",
  43. "digest",
  44. "execution_plan_hash",
  45. "expires_at",
  46. "output_artifact",
  47. "row_count",
  48. "rows_aggregated",
  49. "rows_deduplicated",
  50. "rows_filtered",
  51. "rows_in",
  52. "rows_join_dropped",
  53. "rows_out",
  54. "rows_quarantined",
  55. "rows_rejected",
  56. "rule_version_id",
  57. "schema_hash",
  58. "violation_count",
  59. "violations",
  60. }
  61. _ATTESTATION_RESULT_KEYS = {
  62. "component_binding_id",
  63. "execution_plan_hash",
  64. "rule_version_id",
  65. }
  66. _BACKEND_PUBLIC_RESULT_KEYS = {
  67. "sql_pushdown": _ATTESTATION_RESULT_KEYS
  68. | {
  69. "commit_outcome",
  70. "output_artifact",
  71. "rows_in",
  72. "rows_out",
  73. "rows_quarantined",
  74. "rows_rejected",
  75. },
  76. "polars_batch": _ATTESTATION_RESULT_KEYS
  77. | {
  78. "artifact_ref",
  79. "commit_outcome",
  80. "digest",
  81. "expires_at",
  82. "output_artifact",
  83. "row_count",
  84. "rows_aggregated",
  85. "rows_deduplicated",
  86. "rows_filtered",
  87. "rows_in",
  88. "rows_join_dropped",
  89. "rows_out",
  90. "rows_quarantined",
  91. "rows_rejected",
  92. "schema_hash",
  93. "violation_count",
  94. "violations",
  95. },
  96. "quality_check": _ATTESTATION_RESULT_KEYS
  97. | {
  98. "commit_outcome",
  99. "rows_in",
  100. "rows_out",
  101. "rows_quarantined",
  102. "rows_rejected",
  103. "violation_count",
  104. "violations",
  105. },
  106. }
  107. def _uid(value: Any, label: str) -> str:
  108. try:
  109. return ensure_governance_uid({"uid": str(value)})
  110. except ValueError as exc:
  111. raise ValueError(f"{label} is invalid") from exc
  112. def _uuid(value: Any, label: str) -> str:
  113. try:
  114. return str(uuid.UUID(str(value)))
  115. except (TypeError, ValueError, AttributeError) as exc:
  116. raise ValueError(f"{label} is invalid") from exc
  117. def _canonical_digest(value: Any) -> str:
  118. encoded = json.dumps(
  119. value,
  120. sort_keys=True,
  121. separators=(",", ":"),
  122. ensure_ascii=False,
  123. ).encode("utf-8")
  124. return hashlib.sha256(encoded).hexdigest()
  125. def _bounded_count(value: Any, label: str) -> int:
  126. if isinstance(value, bool):
  127. raise ValueError(f"{label} is invalid")
  128. try:
  129. normalized = int(value or 0)
  130. except (TypeError, ValueError) as exc:
  131. raise ValueError(f"{label} is invalid") from exc
  132. if normalized < 0 or normalized > 10_000_000_000:
  133. raise ValueError(f"{label} is outside the evidence limit")
  134. return normalized
  135. def _sample_fields(sample: list[dict[str, Any]]) -> list[dict[str, Any]]:
  136. names = sorted({str(key) for row in sample for key in row})
  137. if not names:
  138. raise ValueError("violation sample has no fields")
  139. return [
  140. {"name": name, "type": "string", "nullable": True}
  141. for name in names
  142. ]
  143. def validate_public_rule_result(
  144. value: Any,
  145. *,
  146. backend: str | None = None,
  147. ) -> dict[str, Any]:
  148. allowed_keys = (
  149. _BACKEND_PUBLIC_RESULT_KEYS.get(backend)
  150. if backend is not None
  151. else _PUBLIC_RESULT_KEYS
  152. )
  153. if (
  154. not isinstance(value, dict)
  155. or allowed_keys is None
  156. or set(value) - allowed_keys
  157. or any(str(key).startswith("_") for key in value)
  158. or len(
  159. json.dumps(
  160. value,
  161. sort_keys=True,
  162. separators=(",", ":"),
  163. ensure_ascii=False,
  164. ).encode("utf-8")
  165. )
  166. > 32_768
  167. ):
  168. raise ValueError("public rule result is not evidence safe")
  169. for key, item in value.items():
  170. if key == "violations":
  171. if not isinstance(item, list) or len(item) > 100:
  172. raise ValueError(
  173. "public rule result is not evidence safe"
  174. )
  175. for summary in item:
  176. if (
  177. not isinstance(summary, dict)
  178. or set(summary) != {"step_id", "count"}
  179. or not isinstance(summary["step_id"], str)
  180. or isinstance(summary["count"], bool)
  181. or not isinstance(summary["count"], int)
  182. or summary["count"] < 0
  183. ):
  184. raise ValueError(
  185. "public rule result is not evidence safe"
  186. )
  187. elif isinstance(item, (dict, list, tuple, set)):
  188. raise ValueError("public rule result is not evidence safe")
  189. return dict(value)
  190. class PostgresRuleEvidenceWriter:
  191. """Persist one immutable run and at most one expiring violation sample."""
  192. def __init__(
  193. self,
  194. engine,
  195. artifact_store,
  196. *,
  197. sample_ttl_seconds: int = 3600,
  198. lease_seconds: int = 300,
  199. ):
  200. self.engine = engine
  201. self.artifact_store = artifact_store
  202. self.sample_ttl_seconds = int(sample_ttl_seconds)
  203. self.lease_seconds = int(lease_seconds)
  204. if (
  205. self.sample_ttl_seconds < 1
  206. or self.sample_ttl_seconds
  207. > self.artifact_store.max_ttl_seconds
  208. ):
  209. raise ValueError("violation sample TTL is invalid")
  210. if self.lease_seconds < 30 or self.lease_seconds > 900:
  211. raise ValueError("rule execution lease is invalid")
  212. def start(
  213. self,
  214. *,
  215. component_binding_id: str,
  216. rule_version_id: str,
  217. plan_hash: str,
  218. correlation_id: str,
  219. dataflow_uid: str,
  220. deployment_id: str,
  221. environment: str,
  222. workflow_version: int,
  223. node_id: str,
  224. lease_owner: str,
  225. ) -> str:
  226. component = _uid(component_binding_id, "component binding id")
  227. rule = _uid(rule_version_id, "rule version id")
  228. correlation = _uid(correlation_id, "correlation id")
  229. dataflow = _uid(dataflow_uid, "dataflow id")
  230. deployment = _uid(deployment_id, "deployment id")
  231. owner = _uuid(lease_owner, "lease owner")
  232. if environment not in {"development", "test", "production"}:
  233. raise ValueError("deployment environment is invalid")
  234. if _DIGEST.fullmatch(str(plan_hash or "")) is None:
  235. raise ValueError("plan hash is invalid")
  236. if (
  237. isinstance(workflow_version, bool)
  238. or not isinstance(workflow_version, int)
  239. or workflow_version < 1
  240. ):
  241. raise ValueError("workflow version is invalid")
  242. if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,99}", str(node_id)):
  243. raise ValueError("node id is invalid")
  244. evidence_key = _canonical_digest(
  245. {
  246. "component_binding_id": component,
  247. "correlation_id": correlation,
  248. "dataflow_uid": dataflow,
  249. "deployment_id": deployment,
  250. "environment": environment,
  251. "node_id": node_id,
  252. "plan_hash": plan_hash,
  253. "rule_version_id": rule,
  254. "workflow_version": workflow_version,
  255. }
  256. )
  257. with self.engine.begin() as connection:
  258. canonical = connection.execute(
  259. text(
  260. """
  261. SELECT d.id::text AS deployment_id
  262. FROM public.dataflow_component_bindings b
  263. JOIN public.dataflow_versions v
  264. ON v.id = b.dataflow_version_id
  265. JOIN public.rule_execution_plans p
  266. ON p.component_binding_id = b.id
  267. JOIN public.dataflow_deployments d
  268. ON d.dataflow_version_id = v.id
  269. WHERE b.id = CAST(:component_binding_id AS uuid)
  270. AND b.rule_version_id =
  271. CAST(:rule_version_id AS uuid)
  272. AND p.plan_hash = :plan_hash
  273. AND p.status = 'published'
  274. AND b.component_id = :node_id
  275. AND v.dataflow_uid = CAST(:dataflow_uid AS uuid)
  276. AND v.version_no = :workflow_version
  277. AND d.id = CAST(:deployment_id AS uuid)
  278. AND d.environment = :environment
  279. AND d.status IN ('canary','active')
  280. """
  281. ),
  282. {
  283. "component_binding_id": component,
  284. "rule_version_id": rule,
  285. "plan_hash": plan_hash,
  286. "dataflow_uid": dataflow,
  287. "deployment_id": deployment,
  288. "environment": environment,
  289. "workflow_version": workflow_version,
  290. "node_id": node_id,
  291. },
  292. ).mappings().one_or_none()
  293. if canonical is None:
  294. raise ValueError("canonical rule deployment does not match")
  295. existing = connection.execute(
  296. text(
  297. """
  298. SELECT id::text, status, lease_owner::text,
  299. lease_expires_at
  300. FROM public.rule_runs
  301. WHERE evidence_key = :evidence_key
  302. FOR UPDATE
  303. """
  304. ),
  305. {"evidence_key": evidence_key},
  306. ).mappings().one_or_none()
  307. if existing is not None:
  308. if (
  309. existing["status"] == "running"
  310. and existing["lease_expires_at"] is not None
  311. ):
  312. expired = connection.execute(
  313. text(
  314. """
  315. SELECT :lease_expires_at <= CURRENT_TIMESTAMP
  316. """
  317. ),
  318. {
  319. "lease_expires_at": existing[
  320. "lease_expires_at"
  321. ]
  322. },
  323. ).scalar_one()
  324. if expired:
  325. connection.execute(
  326. text(
  327. """
  328. UPDATE public.rule_runs
  329. SET status = 'unknown',
  330. commit_outcome = 'unknown',
  331. failure_code = 'execution_lease_expired',
  332. finished_at = CURRENT_TIMESTAMP,
  333. updated_at = CURRENT_TIMESTAMP
  334. WHERE id = CAST(:id AS uuid)
  335. AND status = 'running'
  336. """
  337. ),
  338. {"id": existing["id"]},
  339. )
  340. return str(existing["id"])
  341. rule_run_id = new_governance_uid()
  342. selected = connection.execute(
  343. text(
  344. """
  345. INSERT INTO public.rule_runs (
  346. id, deployment_id, component_binding_id,
  347. rule_version_id, plan_hash, status,
  348. correlation_id, evidence_key, started_at
  349. , attempt_no, lease_owner, lease_expires_at,
  350. heartbeat_at
  351. ) VALUES (
  352. CAST(:id AS uuid), CAST(:deployment_id AS uuid),
  353. CAST(:component_binding_id AS uuid),
  354. CAST(:rule_version_id AS uuid), :plan_hash,
  355. 'running', CAST(:correlation_id AS uuid),
  356. :evidence_key, CURRENT_TIMESTAMP, 1,
  357. CAST(:lease_owner AS uuid),
  358. CURRENT_TIMESTAMP
  359. + make_interval(secs => :lease_seconds),
  360. CURRENT_TIMESTAMP
  361. )
  362. ON CONFLICT (evidence_key) DO NOTHING
  363. RETURNING id::text
  364. """
  365. ),
  366. {
  367. "id": rule_run_id,
  368. "deployment_id": canonical["deployment_id"],
  369. "component_binding_id": component,
  370. "rule_version_id": rule,
  371. "plan_hash": plan_hash,
  372. "correlation_id": correlation,
  373. "evidence_key": evidence_key,
  374. "lease_owner": owner,
  375. "lease_seconds": self.lease_seconds,
  376. },
  377. ).scalar_one_or_none()
  378. if selected is None:
  379. selected = connection.execute(
  380. text(
  381. """
  382. SELECT id::text
  383. FROM public.rule_runs
  384. WHERE evidence_key = :evidence_key
  385. """
  386. ),
  387. {"evidence_key": evidence_key},
  388. ).scalar_one()
  389. return str(selected)
  390. def heartbeat(self, rule_run_id: str, lease_owner: str) -> None:
  391. run_id = _uid(rule_run_id, "rule run id")
  392. owner = _uuid(lease_owner, "lease owner")
  393. with self.engine.begin() as connection:
  394. updated = connection.execute(
  395. text(
  396. """
  397. UPDATE public.rule_runs
  398. SET heartbeat_at = CURRENT_TIMESTAMP,
  399. lease_expires_at = CURRENT_TIMESTAMP
  400. + make_interval(secs => :lease_seconds),
  401. updated_at = CURRENT_TIMESTAMP
  402. WHERE id = CAST(:id AS uuid)
  403. AND status = 'running'
  404. AND lease_owner = CAST(:lease_owner AS uuid)
  405. """
  406. ),
  407. {
  408. "id": run_id,
  409. "lease_owner": owner,
  410. "lease_seconds": self.lease_seconds,
  411. },
  412. )
  413. if updated.rowcount != 1:
  414. raise ValueError("rule execution lease is not owned")
  415. def stage_sql_output(
  416. self,
  417. rule_run_id: str,
  418. *,
  419. output_binding_id: str,
  420. ttl_seconds: int | None = None,
  421. ) -> str:
  422. run_id = _uid(rule_run_id, "rule run id")
  423. binding_id = _uid(output_binding_id, "output binding id")
  424. if ttl_seconds is None:
  425. ttl_seconds = min(3600, self.sample_ttl_seconds)
  426. if (
  427. isinstance(ttl_seconds, bool)
  428. or not isinstance(ttl_seconds, int)
  429. or ttl_seconds < 1
  430. or ttl_seconds > self.sample_ttl_seconds
  431. ):
  432. raise ValueError("SQL staging TTL is invalid")
  433. receipt_id = new_governance_uid()
  434. with self.engine.begin() as connection:
  435. row = connection.execute(
  436. text(
  437. """
  438. SELECT r.deployment_id::text, r.correlation_id::text,
  439. b.binding_hash, b.object_ref, b.object_kind,
  440. b.access_mode
  441. FROM public.rule_runs r
  442. JOIN public.dataflow_dataset_bindings b
  443. ON b.id = CAST(:binding_id AS uuid)
  444. AND b.dataflow_deployment_id = r.deployment_id
  445. WHERE r.id = CAST(:run_id AS uuid)
  446. AND r.status = 'running'
  447. FOR SHARE OF r, b
  448. """
  449. ),
  450. {"run_id": run_id, "binding_id": binding_id},
  451. ).mappings().one_or_none()
  452. if (
  453. row is None
  454. or row["object_kind"] not in {"table", "view"}
  455. or row["access_mode"] not in {"write", "read_write"}
  456. ):
  457. raise ValueError("SQL staging output binding is invalid")
  458. relation_digest = _canonical_digest(
  459. {
  460. "binding_hash": str(row["binding_hash"]),
  461. "object_kind": str(row["object_kind"]),
  462. "object_ref": str(row["object_ref"]),
  463. }
  464. )
  465. selected = connection.execute(
  466. text(
  467. """
  468. INSERT INTO public.rule_sql_staging_receipts (
  469. id, producer_rule_run_id, deployment_id,
  470. correlation_id, output_binding_id,
  471. output_binding_hash, relation_ref,
  472. relation_digest, commit_outcome, status,
  473. expires_at
  474. ) VALUES (
  475. CAST(:id AS uuid), CAST(:run_id AS uuid),
  476. CAST(:deployment_id AS uuid),
  477. CAST(:correlation_id AS uuid),
  478. CAST(:binding_id AS uuid), :binding_hash,
  479. :relation_ref, :relation_digest, 'committed',
  480. 'pending', CURRENT_TIMESTAMP
  481. + make_interval(secs => :ttl_seconds)
  482. )
  483. ON CONFLICT (
  484. producer_rule_run_id, output_binding_id
  485. ) DO NOTHING
  486. RETURNING id::text
  487. """
  488. ),
  489. {
  490. "id": receipt_id,
  491. "run_id": run_id,
  492. "deployment_id": row["deployment_id"],
  493. "correlation_id": row["correlation_id"],
  494. "binding_id": binding_id,
  495. "binding_hash": str(row["binding_hash"]),
  496. "relation_ref": str(row["object_ref"]),
  497. "relation_digest": relation_digest,
  498. "ttl_seconds": ttl_seconds,
  499. },
  500. ).scalar_one_or_none()
  501. if selected is None:
  502. selected = connection.execute(
  503. text(
  504. """
  505. SELECT id::text
  506. FROM public.rule_sql_staging_receipts
  507. WHERE producer_rule_run_id = CAST(:run_id AS uuid)
  508. AND output_binding_id =
  509. CAST(:binding_id AS uuid)
  510. """
  511. ),
  512. {"run_id": run_id, "binding_id": binding_id},
  513. ).scalar_one()
  514. return f"dataops-staging://{selected}"
  515. def resolve_sql_staging(
  516. self,
  517. receipt_ref: str,
  518. *,
  519. deployment_id: str,
  520. correlation_id: str,
  521. input_binding_id: str,
  522. ) -> dict[str, str]:
  523. match = re.fullmatch(
  524. r"dataops-staging://([0-9a-f-]{36})",
  525. str(receipt_ref or ""),
  526. )
  527. if match is None:
  528. raise ValueError("SQL staging receipt is invalid")
  529. receipt_id = _uid(match.group(1), "SQL staging receipt id")
  530. deployment = _uid(deployment_id, "deployment id")
  531. correlation = _uid(correlation_id, "correlation id")
  532. binding_id = _uid(input_binding_id, "input binding id")
  533. with self.engine.connect() as connection:
  534. row = connection.execute(
  535. text(
  536. """
  537. SELECT s.relation_ref, s.relation_digest,
  538. s.output_binding_hash, b.binding_hash,
  539. b.object_ref, b.object_kind, b.access_mode
  540. FROM public.rule_sql_staging_receipts s
  541. JOIN public.rule_runs r
  542. ON r.id = s.producer_rule_run_id
  543. JOIN public.dataflow_dataset_bindings b
  544. ON b.id = s.output_binding_id
  545. WHERE s.id = CAST(:id AS uuid)
  546. AND s.deployment_id =
  547. CAST(:deployment_id AS uuid)
  548. AND s.correlation_id =
  549. CAST(:correlation_id AS uuid)
  550. AND s.output_binding_id =
  551. CAST(:input_binding_id AS uuid)
  552. AND s.status = 'ready'
  553. AND s.expires_at > CURRENT_TIMESTAMP
  554. AND s.commit_outcome = 'committed'
  555. AND r.status = 'success'
  556. AND r.commit_outcome = 'committed'
  557. AND b.binding_hash = s.output_binding_hash
  558. AND b.access_mode IN ('read','read_write')
  559. """
  560. ),
  561. {
  562. "id": receipt_id,
  563. "deployment_id": deployment,
  564. "correlation_id": correlation,
  565. "input_binding_id": binding_id,
  566. },
  567. ).mappings().one_or_none()
  568. if row is None:
  569. raise ValueError("SQL staging receipt is not executable")
  570. expected_digest = _canonical_digest(
  571. {
  572. "binding_hash": str(row["binding_hash"]),
  573. "object_kind": str(row["object_kind"]),
  574. "object_ref": str(row["object_ref"]),
  575. }
  576. )
  577. if (
  578. expected_digest != str(row["relation_digest"])
  579. or str(row["relation_ref"]) != str(row["object_ref"])
  580. ):
  581. raise ValueError("SQL staging receipt attestation does not match")
  582. return {
  583. "relation_ref": str(row["relation_ref"]),
  584. "relation_digest": str(row["relation_digest"]),
  585. }
  586. def replay(self, rule_run_id: str) -> dict[str, Any] | None:
  587. run_id = _uid(rule_run_id, "rule run id")
  588. with self.engine.connect() as connection:
  589. row = connection.execute(
  590. text(
  591. """
  592. SELECT status, commit_outcome, public_result
  593. FROM public.rule_runs
  594. WHERE id = CAST(:id AS uuid)
  595. """
  596. ),
  597. {"id": run_id},
  598. ).mappings().one_or_none()
  599. if row is None:
  600. raise ValueError("rule run was not found")
  601. if row["status"] in {"queued", "running"}:
  602. return None
  603. result = row["public_result"]
  604. if isinstance(result, str):
  605. result = json.loads(result)
  606. return {
  607. **(dict(result) if isinstance(result, dict) else {}),
  608. "status": str(row["status"]),
  609. "commit_outcome": str(row["commit_outcome"]),
  610. }
  611. def replay_by_lease_owner(
  612. self,
  613. *,
  614. lease_owner: str,
  615. deployment_id: str,
  616. correlation_id: str,
  617. component_binding_id: str,
  618. rule_version_id: str,
  619. plan_hash: str,
  620. ) -> dict[str, Any] | None:
  621. owner = _uuid(lease_owner, "lease owner")
  622. deployment = _uid(deployment_id, "deployment id")
  623. correlation = _uid(correlation_id, "correlation id")
  624. component = _uid(
  625. component_binding_id,
  626. "component binding id",
  627. )
  628. rule = _uid(rule_version_id, "rule version id")
  629. if _DIGEST.fullmatch(str(plan_hash or "")) is None:
  630. raise ValueError("plan hash is invalid")
  631. with self.engine.connect() as connection:
  632. row = connection.execute(
  633. text(
  634. """
  635. SELECT id::text
  636. FROM public.rule_runs
  637. WHERE lease_owner = CAST(:lease_owner AS uuid)
  638. AND deployment_id =
  639. CAST(:deployment_id AS uuid)
  640. AND correlation_id =
  641. CAST(:correlation_id AS uuid)
  642. AND component_binding_id =
  643. CAST(:component_binding_id AS uuid)
  644. AND rule_version_id =
  645. CAST(:rule_version_id AS uuid)
  646. AND plan_hash = :plan_hash
  647. """
  648. ),
  649. {
  650. "lease_owner": owner,
  651. "deployment_id": deployment,
  652. "correlation_id": correlation,
  653. "component_binding_id": component,
  654. "rule_version_id": rule,
  655. "plan_hash": plan_hash,
  656. },
  657. ).scalar_one_or_none()
  658. if row is None:
  659. return None
  660. return self.replay(str(row))
  661. @staticmethod
  662. def _validate_finish(result: Any) -> dict[str, Any]:
  663. if not isinstance(result, dict) or set(result) - _FINISH_KEYS:
  664. raise ValueError("rule evidence result has unsupported fields")
  665. status = result.get("status")
  666. commit_outcome = result.get("commit_outcome", "not_applicable")
  667. if status not in _FINAL_STATUSES:
  668. raise ValueError("rule evidence status is invalid")
  669. if commit_outcome not in _COMMIT_OUTCOMES:
  670. raise ValueError("rule evidence commit outcome is invalid")
  671. timings = result.get("timings", {})
  672. if (
  673. not isinstance(timings, dict)
  674. or set(timings) != {"duration_ms"}
  675. or isinstance(timings.get("duration_ms"), bool)
  676. or not isinstance(timings.get("duration_ms"), int)
  677. or timings["duration_ms"] < 0
  678. or timings["duration_ms"] > 86_400_000
  679. ):
  680. raise ValueError("rule evidence timings are invalid")
  681. public_result = result.get("public_result")
  682. if public_result is not None:
  683. validate_public_rule_result(public_result)
  684. sample = result.get("violation_sample")
  685. if sample is not None:
  686. if (
  687. status != "success"
  688. or not isinstance(sample, list)
  689. or not 1 <= len(sample) <= 100
  690. or result.get("sample_count") != len(sample)
  691. or result.get("redaction_policy")
  692. != "rule-violation-default-v1"
  693. ):
  694. raise ValueError("violation sample is invalid")
  695. for row in sample:
  696. if not isinstance(row, dict):
  697. raise ValueError("violation sample row is invalid")
  698. for value in row.values():
  699. if value not in {None, "[REDACTED]"}:
  700. raise ValueError(
  701. "violation sample contains unredacted values"
  702. )
  703. return {
  704. **result,
  705. "rows_in": _bounded_count(result.get("rows_in"), "rows_in"),
  706. "rows_out": _bounded_count(result.get("rows_out"), "rows_out"),
  707. "rows_rejected": _bounded_count(
  708. result.get("rows_rejected"), "rows_rejected"
  709. ),
  710. "rows_quarantined": _bounded_count(
  711. result.get("rows_quarantined"), "rows_quarantined"
  712. ),
  713. "commit_outcome": commit_outcome,
  714. }
  715. def _prepare_sample(
  716. self,
  717. run_id: str,
  718. correlation_id: str,
  719. sample: list[dict[str, Any]],
  720. redaction_policy: str,
  721. ) -> tuple[str, dict[str, Any], str]:
  722. fields = _sample_fields(sample)
  723. frame = pl.DataFrame(
  724. {
  725. field["name"]: [
  726. row.get(field["name"]) for row in sample
  727. ]
  728. for field in fields
  729. },
  730. schema={field["name"]: pl.String for field in fields},
  731. )
  732. with tempfile.NamedTemporaryFile(
  733. prefix="dataops-rule-violation-",
  734. suffix=".parquet",
  735. delete=False,
  736. ) as handle:
  737. path = handle.name
  738. sample_id = None
  739. prepared = None
  740. try:
  741. frame.write_parquet(path)
  742. prepared = self.artifact_store.prepare_path(
  743. path,
  744. correlation_id,
  745. self.sample_ttl_seconds,
  746. schema_fields=fields,
  747. limits={
  748. "max_rows": min(100, self.artifact_store.max_rows),
  749. "max_artifact_bytes": min(
  750. 4 * 1024 * 1024,
  751. self.artifact_store.max_artifact_bytes,
  752. ),
  753. "memory_limit_bytes": min(
  754. 16 * 1024 * 1024,
  755. self.artifact_store.memory_limit_bytes,
  756. ),
  757. },
  758. )
  759. sample_id = new_governance_uid()
  760. with self.engine.begin() as connection:
  761. row = connection.execute(
  762. text(
  763. """
  764. SELECT id::text, artifact_ref, artifact_digest,
  765. schema_hash, sample_count,
  766. redaction_policy, expires_at, handoff_status
  767. FROM public.rule_violation_samples
  768. WHERE rule_run_id = CAST(:rule_run_id AS uuid)
  769. FOR UPDATE
  770. """
  771. ),
  772. {"rule_run_id": run_id},
  773. ).mappings().one_or_none()
  774. if row is None:
  775. connection.execute(
  776. text(
  777. """
  778. INSERT INTO public.rule_violation_samples (
  779. id, rule_run_id, artifact_ref,
  780. artifact_digest, schema_hash, sample_count,
  781. redaction_policy, expires_at,
  782. handoff_status
  783. ) VALUES (
  784. CAST(:id AS uuid),
  785. CAST(:rule_run_id AS uuid), :artifact_ref,
  786. :artifact_digest, :schema_hash,
  787. :sample_count, :redaction_policy,
  788. CAST(:expires_at AS timestamptz), 'pending'
  789. )
  790. """
  791. ),
  792. {
  793. "id": sample_id,
  794. "rule_run_id": run_id,
  795. "artifact_ref": prepared["artifact_ref"],
  796. "artifact_digest": prepared["digest"],
  797. "schema_hash": prepared["schema_hash"],
  798. "sample_count": len(sample),
  799. "redaction_policy": redaction_policy,
  800. "expires_at": prepared["expires_at"],
  801. },
  802. )
  803. else:
  804. if (
  805. str(row["artifact_digest"]) != prepared["digest"]
  806. or int(row["sample_count"]) != len(sample)
  807. or str(row["redaction_policy"])
  808. != redaction_policy
  809. ):
  810. raise ValueError(
  811. "violation sample evidence is immutable"
  812. )
  813. sample_id = str(row["id"])
  814. prepared.update(
  815. {
  816. "artifact_ref": str(row["artifact_ref"]),
  817. "digest": str(row["artifact_digest"]),
  818. "schema_hash": str(row["schema_hash"]),
  819. "expires_at": str(row["expires_at"]),
  820. }
  821. )
  822. if row["handoff_status"] == "ready":
  823. stored = self.artifact_store.describe(
  824. prepared["artifact_ref"]
  825. )
  826. if (
  827. stored["digest"] != prepared["digest"]
  828. or stored["schema_hash"]
  829. != prepared["schema_hash"]
  830. or stored["row_count"] != len(sample)
  831. ):
  832. raise ValueError(
  833. "ready violation sample does not match storage"
  834. )
  835. return sample_id, prepared, path
  836. if row["handoff_status"] == "failed":
  837. raise ValueError(
  838. "violation sample handoff already failed"
  839. )
  840. self.artifact_store.upload_path(
  841. path,
  842. prepared,
  843. limits={
  844. "max_rows": min(100, self.artifact_store.max_rows),
  845. "max_artifact_bytes": min(
  846. 4 * 1024 * 1024,
  847. self.artifact_store.max_artifact_bytes,
  848. ),
  849. "memory_limit_bytes": min(
  850. 16 * 1024 * 1024,
  851. self.artifact_store.memory_limit_bytes,
  852. ),
  853. },
  854. )
  855. with self.engine.begin() as connection:
  856. updated = connection.execute(
  857. text(
  858. """
  859. UPDATE public.rule_violation_samples
  860. SET handoff_status = 'ready',
  861. updated_at = CURRENT_TIMESTAMP
  862. WHERE id = CAST(:id AS uuid)
  863. AND handoff_status = 'pending'
  864. AND artifact_digest = :artifact_digest
  865. """
  866. ),
  867. {
  868. "id": sample_id,
  869. "artifact_digest": prepared["digest"],
  870. },
  871. )
  872. if updated.rowcount != 1:
  873. state = connection.execute(
  874. text(
  875. """
  876. SELECT handoff_status
  877. FROM public.rule_violation_samples
  878. WHERE id = CAST(:id AS uuid)
  879. """
  880. ),
  881. {"id": sample_id},
  882. ).scalar_one_or_none()
  883. if state != "ready":
  884. raise RuntimeError(
  885. "violation sample finalize outcome is unknown"
  886. )
  887. return sample_id, prepared, path
  888. except Exception:
  889. if sample_id is not None and prepared is not None:
  890. try:
  891. with self.engine.connect() as connection:
  892. committed = connection.execute(
  893. text(
  894. """
  895. SELECT handoff_status, artifact_digest
  896. FROM public.rule_violation_samples
  897. WHERE id = CAST(:id AS uuid)
  898. """
  899. ),
  900. {"id": sample_id},
  901. ).mappings().one_or_none()
  902. if (
  903. committed is not None
  904. and committed["handoff_status"] == "ready"
  905. and str(committed["artifact_digest"])
  906. == prepared["digest"]
  907. ):
  908. return sample_id, prepared, path
  909. except Exception:
  910. pass
  911. with self.engine.begin() as connection:
  912. connection.execute(
  913. text(
  914. """
  915. UPDATE public.rule_violation_samples
  916. SET handoff_status = 'failed',
  917. failure_code = 'sample_handoff_failed',
  918. updated_at = CURRENT_TIMESTAMP
  919. WHERE rule_run_id = CAST(:rule_run_id AS uuid)
  920. AND handoff_status = 'pending'
  921. """
  922. ),
  923. {"rule_run_id": run_id},
  924. )
  925. with suppress(FileNotFoundError):
  926. os.unlink(path)
  927. raise
  928. def finish(self, rule_run_id: str, result: Any) -> None:
  929. run_id = _uid(rule_run_id, "rule run id")
  930. normalized = self._validate_finish(result)
  931. evidence_digest = _canonical_digest(normalized)
  932. sample_path = None
  933. try:
  934. with self.engine.connect() as connection:
  935. current = connection.execute(
  936. text(
  937. """
  938. SELECT status, correlation_id::text,
  939. evidence_digest
  940. FROM public.rule_runs
  941. WHERE id = CAST(:id AS uuid)
  942. """
  943. ),
  944. {"id": run_id},
  945. ).mappings().one_or_none()
  946. if current is None:
  947. raise ValueError("rule run was not found")
  948. if current["status"] not in {"queued", "running"}:
  949. if (
  950. current["status"] == normalized["status"]
  951. and str(current["evidence_digest"] or "")
  952. == evidence_digest
  953. ):
  954. return
  955. raise ValueError("rule run evidence is immutable")
  956. if normalized.get("violation_sample"):
  957. _sample_id, _prepared, sample_path = self._prepare_sample(
  958. run_id,
  959. str(current["correlation_id"]),
  960. normalized["violation_sample"],
  961. normalized["redaction_policy"],
  962. )
  963. try:
  964. with self.engine.begin() as connection:
  965. updated = connection.execute(
  966. text(
  967. """
  968. UPDATE public.rule_runs
  969. SET rows_in = :rows_in,
  970. rows_out = :rows_out,
  971. rows_rejected = :rows_rejected,
  972. rows_quarantined = :rows_quarantined,
  973. status = :status,
  974. timings = CAST(:timings AS jsonb),
  975. commit_outcome = :commit_outcome,
  976. public_result = CAST(:public_result AS jsonb),
  977. failure_code = :failure_code,
  978. evidence_digest = :evidence_digest,
  979. lease_expires_at = NULL,
  980. finished_at = CURRENT_TIMESTAMP,
  981. updated_at = CURRENT_TIMESTAMP
  982. WHERE id = CAST(:id AS uuid)
  983. AND status IN ('queued','running')
  984. """
  985. ),
  986. {
  987. "id": run_id,
  988. "rows_in": normalized["rows_in"],
  989. "rows_out": normalized["rows_out"],
  990. "rows_rejected": normalized[
  991. "rows_rejected"
  992. ],
  993. "rows_quarantined": normalized[
  994. "rows_quarantined"
  995. ],
  996. "status": normalized["status"],
  997. "timings": json.dumps(
  998. normalized["timings"]
  999. ),
  1000. "commit_outcome": normalized[
  1001. "commit_outcome"
  1002. ],
  1003. "public_result": (
  1004. json.dumps(
  1005. normalized.get("public_result")
  1006. )
  1007. if normalized.get("public_result")
  1008. is not None
  1009. else None
  1010. ),
  1011. "failure_code": (
  1012. None
  1013. if normalized["status"] == "success"
  1014. else (
  1015. f"execution_{normalized['status']}"
  1016. )
  1017. ),
  1018. "evidence_digest": evidence_digest,
  1019. },
  1020. )
  1021. if updated.rowcount != 1:
  1022. state = connection.execute(
  1023. text(
  1024. """
  1025. SELECT status, commit_outcome,
  1026. evidence_digest
  1027. FROM public.rule_runs
  1028. WHERE id = CAST(:id AS uuid)
  1029. """
  1030. ),
  1031. {"id": run_id},
  1032. ).mappings().one_or_none()
  1033. if (
  1034. state is None
  1035. or state["status"] != normalized["status"]
  1036. or state["commit_outcome"]
  1037. != normalized["commit_outcome"]
  1038. or str(state["evidence_digest"] or "")
  1039. != evidence_digest
  1040. ):
  1041. raise RuntimeError(
  1042. "rule run finalize outcome is unknown"
  1043. )
  1044. receipt_status = (
  1045. "ready"
  1046. if normalized["status"] == "success"
  1047. and normalized["commit_outcome"] == "committed"
  1048. else "failed"
  1049. )
  1050. connection.execute(
  1051. text(
  1052. """
  1053. UPDATE public.rule_sql_staging_receipts
  1054. SET status = :status,
  1055. ready_at = CASE
  1056. WHEN :status = 'ready'
  1057. THEN CURRENT_TIMESTAMP
  1058. ELSE ready_at
  1059. END,
  1060. commit_outcome = CASE
  1061. WHEN :status = 'ready'
  1062. THEN 'committed'
  1063. ELSE 'unknown'
  1064. END,
  1065. updated_at = CURRENT_TIMESTAMP
  1066. WHERE producer_rule_run_id =
  1067. CAST(:run_id AS uuid)
  1068. AND status = 'pending'
  1069. """
  1070. ),
  1071. {
  1072. "run_id": run_id,
  1073. "status": receipt_status,
  1074. },
  1075. )
  1076. except Exception as exc:
  1077. try:
  1078. with self.engine.connect() as connection:
  1079. terminal = connection.execute(
  1080. text(
  1081. """
  1082. SELECT status, commit_outcome,
  1083. evidence_digest
  1084. FROM public.rule_runs
  1085. WHERE id = CAST(:id AS uuid)
  1086. """
  1087. ),
  1088. {"id": run_id},
  1089. ).mappings().one_or_none()
  1090. except Exception as recheck_exc:
  1091. raise RuntimeError(
  1092. "rule run finalize outcome is unknown"
  1093. ) from recheck_exc
  1094. if (
  1095. terminal is None
  1096. or terminal["status"] != normalized["status"]
  1097. or terminal["commit_outcome"]
  1098. != normalized["commit_outcome"]
  1099. or str(terminal["evidence_digest"] or "")
  1100. != evidence_digest
  1101. ):
  1102. raise RuntimeError(
  1103. "rule run finalize outcome is unknown"
  1104. ) from exc
  1105. finally:
  1106. if sample_path is not None:
  1107. with suppress(FileNotFoundError):
  1108. os.unlink(sample_path)
  1109. def cleanup_expired(self, *, limit: int = 100) -> int:
  1110. if isinstance(limit, bool) or not isinstance(limit, int):
  1111. raise ValueError("cleanup limit is invalid")
  1112. if limit < 1 or limit > 1000:
  1113. raise ValueError("cleanup limit is invalid")
  1114. removed = 0
  1115. with self.engine.begin() as connection:
  1116. rows = connection.execute(
  1117. text(
  1118. """
  1119. SELECT id::text, artifact_ref
  1120. FROM public.rule_violation_samples
  1121. WHERE expires_at <= CURRENT_TIMESTAMP
  1122. AND handoff_status IN ('legacy','ready','failed')
  1123. ORDER BY expires_at, id
  1124. FOR UPDATE SKIP LOCKED
  1125. LIMIT :limit
  1126. """
  1127. ),
  1128. {"limit": limit},
  1129. ).mappings().all()
  1130. for row in rows:
  1131. try:
  1132. self.artifact_store.delete(str(row["artifact_ref"]))
  1133. except Exception:
  1134. continue
  1135. connection.execute(
  1136. text(
  1137. """
  1138. DELETE FROM public.rule_violation_samples
  1139. WHERE id = CAST(:id AS uuid)
  1140. """
  1141. ),
  1142. {"id": str(row["id"])},
  1143. )
  1144. removed += 1
  1145. return removed
  1146. __all__ = [
  1147. "PostgresRuleEvidenceWriter",
  1148. "validate_public_rule_result",
  1149. ]