rule_evidence.py 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492
  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. self.heartbeat_interval_seconds = max(
  213. 5.0,
  214. min(60.0, self.lease_seconds / 3),
  215. )
  216. def start(
  217. self,
  218. *,
  219. component_binding_id: str,
  220. rule_version_id: str,
  221. plan_hash: str,
  222. correlation_id: str,
  223. dataflow_uid: str,
  224. deployment_id: str,
  225. environment: str,
  226. workflow_version: int,
  227. node_id: str,
  228. lease_owner: str,
  229. ) -> str:
  230. component = _uid(component_binding_id, "component binding id")
  231. rule = _uid(rule_version_id, "rule version id")
  232. correlation = _uid(correlation_id, "correlation id")
  233. dataflow = _uid(dataflow_uid, "dataflow id")
  234. deployment = _uid(deployment_id, "deployment id")
  235. owner = _uuid(lease_owner, "lease owner")
  236. if environment not in {"development", "test", "production"}:
  237. raise ValueError("deployment environment is invalid")
  238. if _DIGEST.fullmatch(str(plan_hash or "")) is None:
  239. raise ValueError("plan hash is invalid")
  240. if (
  241. isinstance(workflow_version, bool)
  242. or not isinstance(workflow_version, int)
  243. or workflow_version < 1
  244. ):
  245. raise ValueError("workflow version is invalid")
  246. if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,99}", str(node_id)):
  247. raise ValueError("node id is invalid")
  248. evidence_key = _canonical_digest(
  249. {
  250. "component_binding_id": component,
  251. "correlation_id": correlation,
  252. "dataflow_uid": dataflow,
  253. "deployment_id": deployment,
  254. "environment": environment,
  255. "node_id": node_id,
  256. "plan_hash": plan_hash,
  257. "rule_version_id": rule,
  258. "workflow_version": workflow_version,
  259. }
  260. )
  261. with self.engine.begin() as connection:
  262. canonical = connection.execute(
  263. text(
  264. """
  265. SELECT d.id::text AS deployment_id
  266. FROM public.dataflow_component_bindings b
  267. JOIN public.dataflow_versions v
  268. ON v.id = b.dataflow_version_id
  269. JOIN public.rule_execution_plans p
  270. ON p.component_binding_id = b.id
  271. JOIN public.dataflow_deployments d
  272. ON d.dataflow_version_id = v.id
  273. WHERE b.id = CAST(:component_binding_id AS uuid)
  274. AND b.rule_version_id =
  275. CAST(:rule_version_id AS uuid)
  276. AND p.plan_hash = :plan_hash
  277. AND p.status = 'published'
  278. AND b.component_id = :node_id
  279. AND v.dataflow_uid = CAST(:dataflow_uid AS uuid)
  280. AND v.version_no = :workflow_version
  281. AND d.id = CAST(:deployment_id AS uuid)
  282. AND d.environment = :environment
  283. AND d.status IN ('canary','active')
  284. """
  285. ),
  286. {
  287. "component_binding_id": component,
  288. "rule_version_id": rule,
  289. "plan_hash": plan_hash,
  290. "dataflow_uid": dataflow,
  291. "deployment_id": deployment,
  292. "environment": environment,
  293. "workflow_version": workflow_version,
  294. "node_id": node_id,
  295. },
  296. ).mappings().one_or_none()
  297. if canonical is None:
  298. raise ValueError("canonical rule deployment does not match")
  299. existing = connection.execute(
  300. text(
  301. """
  302. SELECT id::text, status, lease_owner::text,
  303. lease_expires_at
  304. FROM public.rule_runs
  305. WHERE evidence_key = :evidence_key
  306. FOR UPDATE
  307. """
  308. ),
  309. {"evidence_key": evidence_key},
  310. ).mappings().one_or_none()
  311. if existing is not None:
  312. if (
  313. existing["status"] == "running"
  314. and existing["lease_expires_at"] is not None
  315. ):
  316. expired = connection.execute(
  317. text(
  318. """
  319. SELECT :lease_expires_at <= CURRENT_TIMESTAMP
  320. """
  321. ),
  322. {
  323. "lease_expires_at": existing[
  324. "lease_expires_at"
  325. ]
  326. },
  327. ).scalar_one()
  328. if expired:
  329. connection.execute(
  330. text(
  331. """
  332. UPDATE public.rule_runs
  333. SET status = 'unknown',
  334. commit_outcome = 'unknown',
  335. failure_code = 'execution_lease_expired',
  336. finished_at = CURRENT_TIMESTAMP,
  337. updated_at = CURRENT_TIMESTAMP
  338. WHERE id = CAST(:id AS uuid)
  339. AND status = 'running'
  340. """
  341. ),
  342. {"id": existing["id"]},
  343. )
  344. return str(existing["id"])
  345. rule_run_id = new_governance_uid()
  346. selected = connection.execute(
  347. text(
  348. """
  349. INSERT INTO public.rule_runs (
  350. id, deployment_id, component_binding_id,
  351. rule_version_id, plan_hash, status,
  352. correlation_id, evidence_key, started_at
  353. , attempt_no, lease_owner, lease_expires_at,
  354. heartbeat_at
  355. ) VALUES (
  356. CAST(:id AS uuid), CAST(:deployment_id AS uuid),
  357. CAST(:component_binding_id AS uuid),
  358. CAST(:rule_version_id AS uuid), :plan_hash,
  359. 'running', CAST(:correlation_id AS uuid),
  360. :evidence_key, CURRENT_TIMESTAMP, 1,
  361. CAST(:lease_owner AS uuid),
  362. CURRENT_TIMESTAMP
  363. + make_interval(secs => :lease_seconds),
  364. CURRENT_TIMESTAMP
  365. )
  366. ON CONFLICT (evidence_key) DO NOTHING
  367. RETURNING id::text
  368. """
  369. ),
  370. {
  371. "id": rule_run_id,
  372. "deployment_id": canonical["deployment_id"],
  373. "component_binding_id": component,
  374. "rule_version_id": rule,
  375. "plan_hash": plan_hash,
  376. "correlation_id": correlation,
  377. "evidence_key": evidence_key,
  378. "lease_owner": owner,
  379. "lease_seconds": self.lease_seconds,
  380. },
  381. ).scalar_one_or_none()
  382. if selected is None:
  383. selected = connection.execute(
  384. text(
  385. """
  386. SELECT id::text
  387. FROM public.rule_runs
  388. WHERE evidence_key = :evidence_key
  389. """
  390. ),
  391. {"evidence_key": evidence_key},
  392. ).scalar_one()
  393. return str(selected)
  394. def heartbeat(self, rule_run_id: str, lease_owner: str) -> None:
  395. run_id = _uid(rule_run_id, "rule run id")
  396. owner = _uuid(lease_owner, "lease owner")
  397. with self.engine.begin() as connection:
  398. updated = connection.execute(
  399. text(
  400. """
  401. UPDATE public.rule_runs
  402. SET heartbeat_at = CURRENT_TIMESTAMP,
  403. lease_expires_at = CURRENT_TIMESTAMP
  404. + make_interval(secs => :lease_seconds),
  405. updated_at = CURRENT_TIMESTAMP
  406. WHERE id = CAST(:id AS uuid)
  407. AND status = 'running'
  408. AND lease_owner = CAST(:lease_owner AS uuid)
  409. """
  410. ),
  411. {
  412. "id": run_id,
  413. "lease_owner": owner,
  414. "lease_seconds": self.lease_seconds,
  415. },
  416. )
  417. if updated.rowcount != 1:
  418. raise ValueError("rule execution lease is not owned")
  419. def stage_sql_output(
  420. self,
  421. rule_run_id: str,
  422. *,
  423. output_binding_id: str,
  424. ttl_seconds: int | None = None,
  425. ) -> str:
  426. run_id = _uid(rule_run_id, "rule run id")
  427. binding_id = _uid(output_binding_id, "output binding id")
  428. if ttl_seconds is None:
  429. ttl_seconds = min(3600, self.sample_ttl_seconds)
  430. if (
  431. isinstance(ttl_seconds, bool)
  432. or not isinstance(ttl_seconds, int)
  433. or ttl_seconds < 1
  434. or ttl_seconds > self.sample_ttl_seconds
  435. ):
  436. raise ValueError("SQL staging TTL is invalid")
  437. receipt_id = new_governance_uid()
  438. with self.engine.begin() as connection:
  439. row = connection.execute(
  440. text(
  441. """
  442. SELECT r.deployment_id::text, r.correlation_id::text,
  443. b.binding_hash, b.object_ref, b.object_kind,
  444. b.access_mode
  445. FROM public.rule_runs r
  446. JOIN public.dataflow_dataset_bindings b
  447. ON b.id = CAST(:binding_id AS uuid)
  448. AND b.dataflow_deployment_id = r.deployment_id
  449. WHERE r.id = CAST(:run_id AS uuid)
  450. AND r.status = 'running'
  451. FOR SHARE OF r, b
  452. """
  453. ),
  454. {"run_id": run_id, "binding_id": binding_id},
  455. ).mappings().one_or_none()
  456. if (
  457. row is None
  458. or row["object_kind"] not in {"table", "view"}
  459. or row["access_mode"] not in {"write", "read_write"}
  460. ):
  461. raise ValueError("SQL staging output binding is invalid")
  462. relation_digest = _canonical_digest(
  463. {
  464. "binding_hash": str(row["binding_hash"]),
  465. "object_kind": str(row["object_kind"]),
  466. "object_ref": str(row["object_ref"]),
  467. }
  468. )
  469. selected = connection.execute(
  470. text(
  471. """
  472. INSERT INTO public.rule_sql_staging_receipts (
  473. id, producer_rule_run_id, deployment_id,
  474. correlation_id, output_binding_id,
  475. output_binding_hash, relation_ref,
  476. relation_digest, commit_outcome, status,
  477. expires_at
  478. ) VALUES (
  479. CAST(:id AS uuid), CAST(:run_id AS uuid),
  480. CAST(:deployment_id AS uuid),
  481. CAST(:correlation_id AS uuid),
  482. CAST(:binding_id AS uuid), :binding_hash,
  483. :relation_ref, :relation_digest, 'committed',
  484. 'pending', CURRENT_TIMESTAMP
  485. + make_interval(secs => :ttl_seconds)
  486. )
  487. ON CONFLICT (
  488. producer_rule_run_id, output_binding_id
  489. ) DO NOTHING
  490. RETURNING id::text
  491. """
  492. ),
  493. {
  494. "id": receipt_id,
  495. "run_id": run_id,
  496. "deployment_id": row["deployment_id"],
  497. "correlation_id": row["correlation_id"],
  498. "binding_id": binding_id,
  499. "binding_hash": str(row["binding_hash"]),
  500. "relation_ref": str(row["object_ref"]),
  501. "relation_digest": relation_digest,
  502. "ttl_seconds": ttl_seconds,
  503. },
  504. ).scalar_one_or_none()
  505. if selected is None:
  506. selected = connection.execute(
  507. text(
  508. """
  509. SELECT id::text
  510. FROM public.rule_sql_staging_receipts
  511. WHERE producer_rule_run_id = CAST(:run_id AS uuid)
  512. AND output_binding_id =
  513. CAST(:binding_id AS uuid)
  514. """
  515. ),
  516. {"run_id": run_id, "binding_id": binding_id},
  517. ).scalar_one()
  518. return f"dataops-staging://{selected}"
  519. def resolve_sql_staging(
  520. self,
  521. receipt_ref: str,
  522. *,
  523. deployment_id: str,
  524. correlation_id: str,
  525. input_binding_id: str,
  526. ) -> dict[str, str]:
  527. match = re.fullmatch(
  528. r"dataops-staging://([0-9a-f-]{36})",
  529. str(receipt_ref or ""),
  530. )
  531. if match is None:
  532. raise ValueError("SQL staging receipt is invalid")
  533. receipt_id = _uid(match.group(1), "SQL staging receipt id")
  534. deployment = _uid(deployment_id, "deployment id")
  535. correlation = _uid(correlation_id, "correlation id")
  536. binding_id = _uid(input_binding_id, "input binding id")
  537. with self.engine.connect() as connection:
  538. row = connection.execute(
  539. text(
  540. """
  541. SELECT s.relation_ref, s.relation_digest,
  542. s.output_binding_hash, b.binding_hash,
  543. b.object_ref, b.object_kind, b.access_mode
  544. FROM public.rule_sql_staging_receipts s
  545. JOIN public.rule_runs r
  546. ON r.id = s.producer_rule_run_id
  547. JOIN public.dataflow_dataset_bindings b
  548. ON b.id = s.output_binding_id
  549. WHERE s.id = CAST(:id AS uuid)
  550. AND s.deployment_id =
  551. CAST(:deployment_id AS uuid)
  552. AND s.correlation_id =
  553. CAST(:correlation_id AS uuid)
  554. AND s.output_binding_id =
  555. CAST(:input_binding_id AS uuid)
  556. AND s.status = 'ready'
  557. AND s.expires_at > CURRENT_TIMESTAMP
  558. AND s.commit_outcome = 'committed'
  559. AND r.status = 'success'
  560. AND r.commit_outcome = 'committed'
  561. AND b.binding_hash = s.output_binding_hash
  562. AND b.access_mode IN ('read','read_write')
  563. """
  564. ),
  565. {
  566. "id": receipt_id,
  567. "deployment_id": deployment,
  568. "correlation_id": correlation,
  569. "input_binding_id": binding_id,
  570. },
  571. ).mappings().one_or_none()
  572. if row is None:
  573. raise ValueError("SQL staging receipt is not executable")
  574. expected_digest = _canonical_digest(
  575. {
  576. "binding_hash": str(row["binding_hash"]),
  577. "object_kind": str(row["object_kind"]),
  578. "object_ref": str(row["object_ref"]),
  579. }
  580. )
  581. if (
  582. expected_digest != str(row["relation_digest"])
  583. or str(row["relation_ref"]) != str(row["object_ref"])
  584. ):
  585. raise ValueError("SQL staging receipt attestation does not match")
  586. return {
  587. "relation_ref": str(row["relation_ref"]),
  588. "relation_digest": str(row["relation_digest"]),
  589. }
  590. def replay(self, rule_run_id: str) -> dict[str, Any] | None:
  591. run_id = _uid(rule_run_id, "rule run id")
  592. with self.engine.connect() as connection:
  593. row = connection.execute(
  594. text(
  595. """
  596. SELECT status, commit_outcome, public_result
  597. FROM public.rule_runs
  598. WHERE id = CAST(:id AS uuid)
  599. """
  600. ),
  601. {"id": run_id},
  602. ).mappings().one_or_none()
  603. if row is None:
  604. raise ValueError("rule run was not found")
  605. if row["status"] in {"queued", "running"}:
  606. return None
  607. result = row["public_result"]
  608. if isinstance(result, str):
  609. result = json.loads(result)
  610. return {
  611. **(dict(result) if isinstance(result, dict) else {}),
  612. "status": str(row["status"]),
  613. "commit_outcome": str(row["commit_outcome"]),
  614. }
  615. def replay_by_lease_owner(
  616. self,
  617. *,
  618. lease_owner: str,
  619. deployment_id: str,
  620. correlation_id: str,
  621. component_binding_id: str,
  622. rule_version_id: str,
  623. plan_hash: str,
  624. ) -> dict[str, Any] | None:
  625. owner = _uuid(lease_owner, "lease owner")
  626. deployment = _uid(deployment_id, "deployment id")
  627. correlation = _uid(correlation_id, "correlation id")
  628. component = _uid(
  629. component_binding_id,
  630. "component binding id",
  631. )
  632. rule = _uid(rule_version_id, "rule version id")
  633. if _DIGEST.fullmatch(str(plan_hash or "")) is None:
  634. raise ValueError("plan hash is invalid")
  635. with self.engine.connect() as connection:
  636. row = connection.execute(
  637. text(
  638. """
  639. SELECT id::text
  640. FROM public.rule_runs
  641. WHERE lease_owner = CAST(:lease_owner AS uuid)
  642. AND deployment_id =
  643. CAST(:deployment_id AS uuid)
  644. AND correlation_id =
  645. CAST(:correlation_id AS uuid)
  646. AND component_binding_id =
  647. CAST(:component_binding_id AS uuid)
  648. AND rule_version_id =
  649. CAST(:rule_version_id AS uuid)
  650. AND plan_hash = :plan_hash
  651. """
  652. ),
  653. {
  654. "lease_owner": owner,
  655. "deployment_id": deployment,
  656. "correlation_id": correlation,
  657. "component_binding_id": component,
  658. "rule_version_id": rule,
  659. "plan_hash": plan_hash,
  660. },
  661. ).scalar_one_or_none()
  662. if row is None:
  663. return None
  664. return self.replay(str(row))
  665. def reconcile_expired_lease(
  666. self,
  667. *,
  668. lease_owner: str,
  669. deployment_id: str,
  670. correlation_id: str,
  671. component_binding_id: str,
  672. rule_version_id: str,
  673. plan_hash: str,
  674. ) -> dict[str, Any]:
  675. owner = _uuid(lease_owner, "lease owner")
  676. deployment = _uid(deployment_id, "deployment id")
  677. correlation = _uid(correlation_id, "correlation id")
  678. component = _uid(
  679. component_binding_id,
  680. "component binding id",
  681. )
  682. rule = _uid(rule_version_id, "rule version id")
  683. if _DIGEST.fullmatch(str(plan_hash or "")) is None:
  684. raise ValueError("plan hash is invalid")
  685. parameters = {
  686. "lease_owner": owner,
  687. "deployment_id": deployment,
  688. "correlation_id": correlation,
  689. "component_binding_id": component,
  690. "rule_version_id": rule,
  691. "plan_hash": plan_hash,
  692. }
  693. with self.engine.begin() as connection:
  694. row = connection.execute(
  695. text(
  696. """
  697. SELECT id::text, status, lease_expires_at
  698. FROM public.rule_runs
  699. WHERE lease_owner = CAST(:lease_owner AS uuid)
  700. AND deployment_id =
  701. CAST(:deployment_id AS uuid)
  702. AND correlation_id =
  703. CAST(:correlation_id AS uuid)
  704. AND component_binding_id =
  705. CAST(:component_binding_id AS uuid)
  706. AND rule_version_id =
  707. CAST(:rule_version_id AS uuid)
  708. AND plan_hash = :plan_hash
  709. FOR UPDATE
  710. """
  711. ),
  712. parameters,
  713. ).mappings().one_or_none()
  714. if row is None:
  715. return {"state": "missing"}
  716. if (
  717. row["status"] == "running"
  718. and row["lease_expires_at"] is not None
  719. ):
  720. expired = connection.execute(
  721. text(
  722. "SELECT :lease_expires_at <= CURRENT_TIMESTAMP"
  723. ),
  724. {"lease_expires_at": row["lease_expires_at"]},
  725. ).scalar_one()
  726. if expired:
  727. connection.execute(
  728. text(
  729. """
  730. UPDATE public.rule_runs
  731. SET status = 'unknown',
  732. commit_outcome = 'unknown',
  733. failure_code =
  734. 'execution_lease_expired',
  735. lease_expires_at = NULL,
  736. finished_at = CURRENT_TIMESTAMP,
  737. updated_at = CURRENT_TIMESTAMP
  738. WHERE id = CAST(:id AS uuid)
  739. AND status = 'running'
  740. """
  741. ),
  742. {"id": row["id"]},
  743. )
  744. connection.execute(
  745. text(
  746. """
  747. UPDATE public.rule_sql_staging_receipts
  748. SET status = 'failed',
  749. commit_outcome = 'unknown',
  750. updated_at = CURRENT_TIMESTAMP
  751. WHERE producer_rule_run_id =
  752. CAST(:id AS uuid)
  753. AND status = 'pending'
  754. """
  755. ),
  756. {"id": row["id"]},
  757. )
  758. row = {**row, "status": "unknown"}
  759. if row["status"] == "running":
  760. return {"state": "running"}
  761. run_id = str(row["id"])
  762. replay = self.replay(run_id)
  763. return {
  764. "state": "terminal",
  765. "status": str(replay["status"]),
  766. "commit_outcome": str(replay["commit_outcome"]),
  767. "result": {
  768. key: value
  769. for key, value in replay.items()
  770. if key != "status"
  771. },
  772. }
  773. @staticmethod
  774. def _validate_finish(result: Any) -> dict[str, Any]:
  775. if not isinstance(result, dict) or set(result) - _FINISH_KEYS:
  776. raise ValueError("rule evidence result has unsupported fields")
  777. status = result.get("status")
  778. commit_outcome = result.get("commit_outcome", "not_applicable")
  779. if status not in _FINAL_STATUSES:
  780. raise ValueError("rule evidence status is invalid")
  781. if commit_outcome not in _COMMIT_OUTCOMES:
  782. raise ValueError("rule evidence commit outcome is invalid")
  783. timings = result.get("timings", {})
  784. if (
  785. not isinstance(timings, dict)
  786. or set(timings) != {"duration_ms"}
  787. or isinstance(timings.get("duration_ms"), bool)
  788. or not isinstance(timings.get("duration_ms"), int)
  789. or timings["duration_ms"] < 0
  790. or timings["duration_ms"] > 86_400_000
  791. ):
  792. raise ValueError("rule evidence timings are invalid")
  793. public_result = result.get("public_result")
  794. if public_result is not None:
  795. validate_public_rule_result(public_result)
  796. sample = result.get("violation_sample")
  797. if sample is not None:
  798. if (
  799. status != "success"
  800. or not isinstance(sample, list)
  801. or not 1 <= len(sample) <= 100
  802. or result.get("sample_count") != len(sample)
  803. or result.get("redaction_policy")
  804. != "rule-violation-default-v1"
  805. ):
  806. raise ValueError("violation sample is invalid")
  807. for row in sample:
  808. if not isinstance(row, dict):
  809. raise ValueError("violation sample row is invalid")
  810. for value in row.values():
  811. if value not in {None, "[REDACTED]"}:
  812. raise ValueError(
  813. "violation sample contains unredacted values"
  814. )
  815. return {
  816. **result,
  817. "rows_in": _bounded_count(result.get("rows_in"), "rows_in"),
  818. "rows_out": _bounded_count(result.get("rows_out"), "rows_out"),
  819. "rows_rejected": _bounded_count(
  820. result.get("rows_rejected"), "rows_rejected"
  821. ),
  822. "rows_quarantined": _bounded_count(
  823. result.get("rows_quarantined"), "rows_quarantined"
  824. ),
  825. "commit_outcome": commit_outcome,
  826. }
  827. def _prepare_sample(
  828. self,
  829. run_id: str,
  830. correlation_id: str,
  831. sample: list[dict[str, Any]],
  832. redaction_policy: str,
  833. ) -> tuple[str, dict[str, Any], str]:
  834. fields = _sample_fields(sample)
  835. frame = pl.DataFrame(
  836. {
  837. field["name"]: [
  838. row.get(field["name"]) for row in sample
  839. ]
  840. for field in fields
  841. },
  842. schema={field["name"]: pl.String for field in fields},
  843. )
  844. with tempfile.NamedTemporaryFile(
  845. prefix="dataops-rule-violation-",
  846. suffix=".parquet",
  847. delete=False,
  848. ) as handle:
  849. path = handle.name
  850. sample_id = None
  851. prepared = None
  852. uploaded = False
  853. try:
  854. frame.write_parquet(path)
  855. prepared = self.artifact_store.prepare_path(
  856. path,
  857. correlation_id,
  858. self.sample_ttl_seconds,
  859. schema_fields=fields,
  860. limits={
  861. "max_rows": min(100, self.artifact_store.max_rows),
  862. "max_artifact_bytes": min(
  863. 4 * 1024 * 1024,
  864. self.artifact_store.max_artifact_bytes,
  865. ),
  866. "memory_limit_bytes": min(
  867. 16 * 1024 * 1024,
  868. self.artifact_store.memory_limit_bytes,
  869. ),
  870. },
  871. )
  872. sample_id = new_governance_uid()
  873. with self.engine.begin() as connection:
  874. row = connection.execute(
  875. text(
  876. """
  877. SELECT id::text, artifact_ref, artifact_digest,
  878. schema_hash, sample_count,
  879. redaction_policy, expires_at, handoff_status
  880. FROM public.rule_violation_samples
  881. WHERE rule_run_id = CAST(:rule_run_id AS uuid)
  882. FOR UPDATE
  883. """
  884. ),
  885. {"rule_run_id": run_id},
  886. ).mappings().one_or_none()
  887. if row is None:
  888. connection.execute(
  889. text(
  890. """
  891. INSERT INTO public.rule_violation_samples (
  892. id, rule_run_id, artifact_ref,
  893. artifact_digest, schema_hash, sample_count,
  894. redaction_policy, expires_at,
  895. handoff_status, schema_fields
  896. ) VALUES (
  897. CAST(:id AS uuid),
  898. CAST(:rule_run_id AS uuid), :artifact_ref,
  899. :artifact_digest, :schema_hash,
  900. :sample_count, :redaction_policy,
  901. CAST(:expires_at AS timestamptz), 'pending',
  902. CAST(:schema_fields AS jsonb)
  903. )
  904. """
  905. ),
  906. {
  907. "id": sample_id,
  908. "rule_run_id": run_id,
  909. "artifact_ref": prepared["artifact_ref"],
  910. "artifact_digest": prepared["digest"],
  911. "schema_hash": prepared["schema_hash"],
  912. "sample_count": len(sample),
  913. "redaction_policy": redaction_policy,
  914. "expires_at": prepared["expires_at"],
  915. "schema_fields": json.dumps(fields),
  916. },
  917. )
  918. else:
  919. if (
  920. str(row["artifact_digest"]) != prepared["digest"]
  921. or int(row["sample_count"]) != len(sample)
  922. or str(row["redaction_policy"])
  923. != redaction_policy
  924. ):
  925. raise ValueError(
  926. "violation sample evidence is immutable"
  927. )
  928. sample_id = str(row["id"])
  929. prepared.update(
  930. {
  931. "artifact_ref": str(row["artifact_ref"]),
  932. "digest": str(row["artifact_digest"]),
  933. "schema_hash": str(row["schema_hash"]),
  934. "expires_at": str(row["expires_at"]),
  935. }
  936. )
  937. if row["handoff_status"] == "ready":
  938. stored = self.artifact_store.describe(
  939. prepared["artifact_ref"]
  940. )
  941. if (
  942. stored["digest"] != prepared["digest"]
  943. or stored["schema_hash"]
  944. != prepared["schema_hash"]
  945. or stored["row_count"] != len(sample)
  946. ):
  947. raise ValueError(
  948. "ready violation sample does not match storage"
  949. )
  950. return sample_id, prepared, path
  951. if row["handoff_status"] == "failed":
  952. raise ValueError(
  953. "violation sample handoff already failed"
  954. )
  955. self.artifact_store.upload_path(
  956. path,
  957. prepared,
  958. limits={
  959. "max_rows": min(100, self.artifact_store.max_rows),
  960. "max_artifact_bytes": min(
  961. 4 * 1024 * 1024,
  962. self.artifact_store.max_artifact_bytes,
  963. ),
  964. "memory_limit_bytes": min(
  965. 16 * 1024 * 1024,
  966. self.artifact_store.memory_limit_bytes,
  967. ),
  968. },
  969. )
  970. uploaded = True
  971. with self.engine.begin() as connection:
  972. updated = connection.execute(
  973. text(
  974. """
  975. UPDATE public.rule_violation_samples
  976. SET handoff_status = 'ready',
  977. updated_at = CURRENT_TIMESTAMP
  978. WHERE id = CAST(:id AS uuid)
  979. AND handoff_status = 'pending'
  980. AND artifact_digest = :artifact_digest
  981. """
  982. ),
  983. {
  984. "id": sample_id,
  985. "artifact_digest": prepared["digest"],
  986. },
  987. )
  988. if updated.rowcount != 1:
  989. state = connection.execute(
  990. text(
  991. """
  992. SELECT handoff_status
  993. FROM public.rule_violation_samples
  994. WHERE id = CAST(:id AS uuid)
  995. """
  996. ),
  997. {"id": sample_id},
  998. ).scalar_one_or_none()
  999. if state != "ready":
  1000. raise RuntimeError(
  1001. "violation sample finalize outcome is unknown"
  1002. )
  1003. return sample_id, prepared, path
  1004. except Exception:
  1005. if sample_id is not None and prepared is not None:
  1006. try:
  1007. with self.engine.connect() as connection:
  1008. committed = connection.execute(
  1009. text(
  1010. """
  1011. SELECT handoff_status, artifact_digest
  1012. FROM public.rule_violation_samples
  1013. WHERE id = CAST(:id AS uuid)
  1014. """
  1015. ),
  1016. {"id": sample_id},
  1017. ).mappings().one_or_none()
  1018. if (
  1019. committed is not None
  1020. and committed["handoff_status"] == "ready"
  1021. and str(committed["artifact_digest"])
  1022. == prepared["digest"]
  1023. ):
  1024. return sample_id, prepared, path
  1025. except Exception:
  1026. pass
  1027. with self.engine.begin() as connection:
  1028. connection.execute(
  1029. text(
  1030. """
  1031. UPDATE public.rule_violation_samples
  1032. SET handoff_status = :handoff_status,
  1033. failure_code = :failure_code,
  1034. updated_at = CURRENT_TIMESTAMP
  1035. WHERE rule_run_id = CAST(:rule_run_id AS uuid)
  1036. AND handoff_status = 'pending'
  1037. """
  1038. ),
  1039. {
  1040. "rule_run_id": run_id,
  1041. "handoff_status": (
  1042. "unknown" if uploaded else "failed"
  1043. ),
  1044. "failure_code": (
  1045. "sample_finalize_unknown"
  1046. if uploaded
  1047. else "sample_handoff_failed"
  1048. ),
  1049. },
  1050. )
  1051. raise
  1052. finally:
  1053. with suppress(FileNotFoundError):
  1054. os.unlink(path)
  1055. def finish(self, rule_run_id: str, result: Any) -> None:
  1056. run_id = _uid(rule_run_id, "rule run id")
  1057. normalized = self._validate_finish(result)
  1058. evidence_digest = _canonical_digest(normalized)
  1059. sample_path = None
  1060. try:
  1061. with self.engine.connect() as connection:
  1062. current = connection.execute(
  1063. text(
  1064. """
  1065. SELECT status, correlation_id::text,
  1066. evidence_digest
  1067. FROM public.rule_runs
  1068. WHERE id = CAST(:id AS uuid)
  1069. """
  1070. ),
  1071. {"id": run_id},
  1072. ).mappings().one_or_none()
  1073. if current is None:
  1074. raise ValueError("rule run was not found")
  1075. if current["status"] not in {"queued", "running"}:
  1076. if (
  1077. current["status"] == normalized["status"]
  1078. and str(current["evidence_digest"] or "")
  1079. == evidence_digest
  1080. ):
  1081. return
  1082. raise ValueError("rule run evidence is immutable")
  1083. if normalized.get("violation_sample"):
  1084. _sample_id, _prepared, sample_path = self._prepare_sample(
  1085. run_id,
  1086. str(current["correlation_id"]),
  1087. normalized["violation_sample"],
  1088. normalized["redaction_policy"],
  1089. )
  1090. try:
  1091. with self.engine.begin() as connection:
  1092. updated = connection.execute(
  1093. text(
  1094. """
  1095. UPDATE public.rule_runs
  1096. SET rows_in = :rows_in,
  1097. rows_out = :rows_out,
  1098. rows_rejected = :rows_rejected,
  1099. rows_quarantined = :rows_quarantined,
  1100. status = :status,
  1101. timings = CAST(:timings AS jsonb),
  1102. commit_outcome = :commit_outcome,
  1103. public_result = CAST(:public_result AS jsonb),
  1104. failure_code = :failure_code,
  1105. evidence_digest = :evidence_digest,
  1106. lease_expires_at = NULL,
  1107. finished_at = CURRENT_TIMESTAMP,
  1108. updated_at = CURRENT_TIMESTAMP
  1109. WHERE id = CAST(:id AS uuid)
  1110. AND status IN ('queued','running')
  1111. """
  1112. ),
  1113. {
  1114. "id": run_id,
  1115. "rows_in": normalized["rows_in"],
  1116. "rows_out": normalized["rows_out"],
  1117. "rows_rejected": normalized[
  1118. "rows_rejected"
  1119. ],
  1120. "rows_quarantined": normalized[
  1121. "rows_quarantined"
  1122. ],
  1123. "status": normalized["status"],
  1124. "timings": json.dumps(
  1125. normalized["timings"]
  1126. ),
  1127. "commit_outcome": normalized[
  1128. "commit_outcome"
  1129. ],
  1130. "public_result": (
  1131. json.dumps(
  1132. normalized.get("public_result")
  1133. )
  1134. if normalized.get("public_result")
  1135. is not None
  1136. else None
  1137. ),
  1138. "failure_code": (
  1139. None
  1140. if normalized["status"] == "success"
  1141. else (
  1142. f"execution_{normalized['status']}"
  1143. )
  1144. ),
  1145. "evidence_digest": evidence_digest,
  1146. },
  1147. )
  1148. if updated.rowcount != 1:
  1149. state = connection.execute(
  1150. text(
  1151. """
  1152. SELECT status, commit_outcome,
  1153. evidence_digest
  1154. FROM public.rule_runs
  1155. WHERE id = CAST(:id AS uuid)
  1156. """
  1157. ),
  1158. {"id": run_id},
  1159. ).mappings().one_or_none()
  1160. if (
  1161. state is None
  1162. or state["status"] != normalized["status"]
  1163. or state["commit_outcome"]
  1164. != normalized["commit_outcome"]
  1165. or str(state["evidence_digest"] or "")
  1166. != evidence_digest
  1167. ):
  1168. raise RuntimeError(
  1169. "rule run finalize outcome is unknown"
  1170. )
  1171. receipt_status = (
  1172. "ready"
  1173. if normalized["status"] == "success"
  1174. and normalized["commit_outcome"] == "committed"
  1175. else "failed"
  1176. )
  1177. connection.execute(
  1178. text(
  1179. """
  1180. UPDATE public.rule_sql_staging_receipts
  1181. SET status = :status,
  1182. ready_at = CASE
  1183. WHEN :status = 'ready'
  1184. THEN CURRENT_TIMESTAMP
  1185. ELSE ready_at
  1186. END,
  1187. commit_outcome = CASE
  1188. WHEN :status = 'ready'
  1189. THEN 'committed'
  1190. ELSE 'unknown'
  1191. END,
  1192. updated_at = CURRENT_TIMESTAMP
  1193. WHERE producer_rule_run_id =
  1194. CAST(:run_id AS uuid)
  1195. AND status = 'pending'
  1196. """
  1197. ),
  1198. {
  1199. "run_id": run_id,
  1200. "status": receipt_status,
  1201. },
  1202. )
  1203. except Exception as exc:
  1204. try:
  1205. with self.engine.connect() as connection:
  1206. terminal = connection.execute(
  1207. text(
  1208. """
  1209. SELECT status, commit_outcome,
  1210. evidence_digest
  1211. FROM public.rule_runs
  1212. WHERE id = CAST(:id AS uuid)
  1213. """
  1214. ),
  1215. {"id": run_id},
  1216. ).mappings().one_or_none()
  1217. except Exception as recheck_exc:
  1218. raise RuntimeError(
  1219. "rule run finalize outcome is unknown"
  1220. ) from recheck_exc
  1221. if (
  1222. terminal is None
  1223. or terminal["status"] != normalized["status"]
  1224. or terminal["commit_outcome"]
  1225. != normalized["commit_outcome"]
  1226. or str(terminal["evidence_digest"] or "")
  1227. != evidence_digest
  1228. ):
  1229. raise RuntimeError(
  1230. "rule run finalize outcome is unknown"
  1231. ) from exc
  1232. finally:
  1233. if sample_path is not None:
  1234. with suppress(FileNotFoundError):
  1235. os.unlink(sample_path)
  1236. def reconcile_samples(self, *, limit: int = 100) -> dict[str, int]:
  1237. if isinstance(limit, bool) or not isinstance(limit, int):
  1238. raise ValueError("cleanup limit is invalid")
  1239. if limit < 1 or limit > 1000:
  1240. raise ValueError("cleanup limit is invalid")
  1241. claim = new_governance_uid()
  1242. with self.engine.begin() as connection:
  1243. rows = connection.execute(
  1244. text(
  1245. """
  1246. SELECT id::text, artifact_ref, artifact_digest,
  1247. schema_hash, schema_fields, sample_count,
  1248. handoff_status,
  1249. expires_at <= CURRENT_TIMESTAMP AS expired
  1250. FROM public.rule_violation_samples
  1251. WHERE cleanup_claim IS NULL
  1252. AND (
  1253. handoff_status IN ('pending','unknown')
  1254. OR (
  1255. expires_at <= CURRENT_TIMESTAMP
  1256. AND handoff_status IN (
  1257. 'legacy','ready','failed'
  1258. )
  1259. )
  1260. )
  1261. ORDER BY expires_at, id
  1262. FOR UPDATE SKIP LOCKED
  1263. LIMIT :limit
  1264. """
  1265. ),
  1266. {"limit": limit},
  1267. ).mappings().all()
  1268. for row in rows:
  1269. connection.execute(
  1270. text(
  1271. """
  1272. UPDATE public.rule_violation_samples
  1273. SET cleanup_claim = CAST(:claim AS uuid),
  1274. updated_at = CURRENT_TIMESTAMP
  1275. WHERE id = CAST(:id AS uuid)
  1276. AND cleanup_claim IS NULL
  1277. """
  1278. ),
  1279. {"id": str(row["id"]), "claim": claim},
  1280. )
  1281. metrics = {
  1282. "claimed": len(rows),
  1283. "ready": 0,
  1284. "failed": 0,
  1285. "expired_deleted": 0,
  1286. }
  1287. for row in rows:
  1288. row_id = str(row["id"])
  1289. if bool(row["expired"]):
  1290. try:
  1291. self.artifact_store.delete(str(row["artifact_ref"]))
  1292. except Exception:
  1293. with self.engine.begin() as connection:
  1294. connection.execute(
  1295. text(
  1296. """
  1297. UPDATE public.rule_violation_samples
  1298. SET cleanup_claim = NULL,
  1299. updated_at = CURRENT_TIMESTAMP
  1300. WHERE id = CAST(:id AS uuid)
  1301. AND cleanup_claim =
  1302. CAST(:claim AS uuid)
  1303. """
  1304. ),
  1305. {"id": row_id, "claim": claim},
  1306. )
  1307. continue
  1308. with self.engine.begin() as connection:
  1309. deleted = connection.execute(
  1310. text(
  1311. """
  1312. DELETE FROM public.rule_violation_samples
  1313. WHERE id = CAST(:id AS uuid)
  1314. AND cleanup_claim = CAST(:claim AS uuid)
  1315. """
  1316. ),
  1317. {"id": row_id, "claim": claim},
  1318. )
  1319. metrics["expired_deleted"] += int(
  1320. deleted.rowcount or 0
  1321. )
  1322. continue
  1323. next_status = row["handoff_status"]
  1324. failure_code = None
  1325. try:
  1326. fields = row["schema_fields"]
  1327. if isinstance(fields, str):
  1328. fields = json.loads(fields)
  1329. if not isinstance(fields, list):
  1330. raise ValueError(
  1331. "violation sample schema is unavailable"
  1332. )
  1333. with self.artifact_store.stage(
  1334. str(row["artifact_ref"]),
  1335. str(row["artifact_digest"]),
  1336. expected_schema_fields=fields,
  1337. limits={
  1338. "max_rows": min(
  1339. 100,
  1340. self.artifact_store.max_rows,
  1341. ),
  1342. "max_artifact_bytes": min(
  1343. 4 * 1024 * 1024,
  1344. self.artifact_store.max_artifact_bytes,
  1345. ),
  1346. "memory_limit_bytes": min(
  1347. 16 * 1024 * 1024,
  1348. self.artifact_store.memory_limit_bytes,
  1349. ),
  1350. },
  1351. ):
  1352. pass
  1353. described = self.artifact_store.describe(
  1354. str(row["artifact_ref"])
  1355. )
  1356. if (
  1357. described["schema_hash"] != row["schema_hash"]
  1358. or described["row_count"]
  1359. != int(row["sample_count"])
  1360. ):
  1361. raise ValueError(
  1362. "violation sample attestation does not match"
  1363. )
  1364. next_status = "ready"
  1365. metrics["ready"] += 1
  1366. except ValueError:
  1367. next_status = "failed"
  1368. failure_code = "sample_reconcile_invalid"
  1369. metrics["failed"] += 1
  1370. except Exception:
  1371. next_status = row["handoff_status"]
  1372. with self.engine.begin() as connection:
  1373. connection.execute(
  1374. text(
  1375. """
  1376. UPDATE public.rule_violation_samples
  1377. SET handoff_status = :handoff_status,
  1378. failure_code = :failure_code,
  1379. cleanup_claim = NULL,
  1380. updated_at = CURRENT_TIMESTAMP
  1381. WHERE id = CAST(:id AS uuid)
  1382. AND cleanup_claim = CAST(:claim AS uuid)
  1383. """
  1384. ),
  1385. {
  1386. "id": row_id,
  1387. "claim": claim,
  1388. "handoff_status": next_status,
  1389. "failure_code": failure_code,
  1390. },
  1391. )
  1392. return metrics
  1393. def cleanup_sql_staging(self, *, limit: int = 100) -> int:
  1394. if (
  1395. isinstance(limit, bool)
  1396. or not isinstance(limit, int)
  1397. or limit < 1
  1398. or limit > 1000
  1399. ):
  1400. raise ValueError("cleanup limit is invalid")
  1401. claim = new_governance_uid()
  1402. with self.engine.begin() as connection:
  1403. rows = connection.execute(
  1404. text(
  1405. """
  1406. SELECT id::text
  1407. FROM public.rule_sql_staging_receipts
  1408. WHERE expires_at <= CURRENT_TIMESTAMP
  1409. AND status IN ('pending','ready','failed')
  1410. AND cleanup_claim IS NULL
  1411. ORDER BY expires_at, id
  1412. FOR UPDATE SKIP LOCKED
  1413. LIMIT :limit
  1414. """
  1415. ),
  1416. {"limit": limit},
  1417. ).mappings().all()
  1418. for row in rows:
  1419. connection.execute(
  1420. text(
  1421. """
  1422. UPDATE public.rule_sql_staging_receipts
  1423. SET cleanup_claim = CAST(:claim AS uuid),
  1424. updated_at = CURRENT_TIMESTAMP
  1425. WHERE id = CAST(:id AS uuid)
  1426. AND cleanup_claim IS NULL
  1427. """
  1428. ),
  1429. {"id": str(row["id"]), "claim": claim},
  1430. )
  1431. finalized = 0
  1432. for row in rows:
  1433. with self.engine.begin() as connection:
  1434. updated = connection.execute(
  1435. text(
  1436. """
  1437. UPDATE public.rule_sql_staging_receipts
  1438. SET status = 'expired',
  1439. cleanup_claim = NULL,
  1440. updated_at = CURRENT_TIMESTAMP
  1441. WHERE id = CAST(:id AS uuid)
  1442. AND cleanup_claim = CAST(:claim AS uuid)
  1443. AND expires_at <= CURRENT_TIMESTAMP
  1444. """
  1445. ),
  1446. {"id": str(row["id"]), "claim": claim},
  1447. )
  1448. finalized += int(updated.rowcount or 0)
  1449. return finalized
  1450. def cleanup_expired(self, *, limit: int = 100) -> int:
  1451. samples = self.reconcile_samples(limit=limit)
  1452. receipts = self.cleanup_sql_staging(limit=limit)
  1453. return samples["expired_deleted"] + receipts
  1454. __all__ = [
  1455. "PostgresRuleEvidenceWriter",
  1456. "validate_public_rule_result",
  1457. ]