publication.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972
  1. """Trusted generation receipts and rule publication lifecycle gates."""
  2. from __future__ import annotations
  3. import base64
  4. import copy
  5. import hashlib
  6. import hmac
  7. import json
  8. import os
  9. import re
  10. import tempfile
  11. from contextlib import ExitStack, suppress
  12. from datetime import UTC, datetime
  13. from typing import Any
  14. from app.core.common.identifiers import ensure_governance_uid, new_governance_uid
  15. from app.core.data_rules.contracts import rule_spec_hash, validate_rule_spec
  16. _DIGEST = re.compile(r"^[0-9a-f]{64}$")
  17. _RECEIPT_KEYS = {
  18. "version",
  19. "generation_run_id",
  20. "actor_uid",
  21. "source_text_hash",
  22. "candidate_hash",
  23. "rule_spec_hash",
  24. "model_hash",
  25. "prompt_hash",
  26. "context_hash",
  27. "issued_at",
  28. "expires_at",
  29. }
  30. _COMPILED_KEYS = {
  31. "backend",
  32. "compiler_version",
  33. "plan",
  34. "plan_hash",
  35. "schema_hashes",
  36. "binding_hashes",
  37. "capabilities",
  38. }
  39. _TEST_KEYS = {
  40. "status",
  41. "test_kind",
  42. "run_id",
  43. "plan_hash",
  44. "schema_hashes",
  45. "binding_hashes",
  46. "counts",
  47. "attestation",
  48. }
  49. class RuleValidationRejected(ValueError):
  50. """A deterministic validation failure whose audit row must be committed."""
  51. def _canonical(value: Any) -> bytes:
  52. try:
  53. encoded = json.dumps(
  54. value,
  55. sort_keys=True,
  56. separators=(",", ":"),
  57. ensure_ascii=False,
  58. )
  59. except (TypeError, ValueError) as exc:
  60. raise ValueError("publication value must be JSON serializable") from exc
  61. return encoded.encode("utf-8")
  62. def _digest(value: Any, label: str) -> str:
  63. normalized = str(value or "")
  64. if not _DIGEST.fullmatch(normalized):
  65. raise ValueError(f"{label} must be a sha256 digest")
  66. return normalized
  67. def _uid(value: Any, label: str) -> str:
  68. try:
  69. return ensure_governance_uid({"uid": str(value)})
  70. except ValueError as exc:
  71. raise ValueError(f"{label} must be a valid UUIDv7") from exc
  72. def _hash_text(value: Any, label: str, maximum: int = 20_000) -> str:
  73. if not isinstance(value, str) or not value.strip():
  74. raise ValueError(f"{label} is required")
  75. normalized = value.strip()
  76. if len(normalized) > maximum:
  77. raise ValueError(f"{label} exceeds {maximum} characters")
  78. return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
  79. def _b64encode(value: bytes) -> str:
  80. return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
  81. def _b64decode(value: str) -> bytes:
  82. if (
  83. not isinstance(value, str)
  84. or not value
  85. or re.fullmatch(r"[A-Za-z0-9_-]+", value) is None
  86. ):
  87. raise ValueError("generation receipt encoding is invalid")
  88. try:
  89. decoded = base64.urlsafe_b64decode(
  90. value + ("=" * (-len(value) % 4))
  91. )
  92. except Exception as exc:
  93. raise ValueError("generation receipt encoding is invalid") from exc
  94. if _b64encode(decoded) != value:
  95. raise ValueError("generation receipt encoding is not canonical")
  96. return decoded
  97. def generation_receipt_claims(
  98. *,
  99. generation_run_id: str,
  100. actor_uid: str,
  101. source_text: str,
  102. candidate_hash: str,
  103. rule_spec: dict[str, Any],
  104. model_hash: str,
  105. prompt_hash: str,
  106. context_hash: str,
  107. issued_at: datetime | None = None,
  108. expires_at: datetime,
  109. ) -> dict[str, Any]:
  110. """Build the closed, non-secret claim set signed by ``/interpret``."""
  111. issued = issued_at or datetime.now(UTC)
  112. if not isinstance(issued, datetime) or issued.tzinfo is None:
  113. raise ValueError("generation receipt issue time must be timezone aware")
  114. if not isinstance(expires_at, datetime) or expires_at.tzinfo is None:
  115. raise ValueError("generation receipt expiry must be timezone aware")
  116. spec = validate_rule_spec(rule_spec)
  117. return {
  118. "version": "1",
  119. "generation_run_id": _uid(
  120. generation_run_id, "generation_run_id"
  121. ),
  122. "actor_uid": _uid(actor_uid, "actor_uid"),
  123. "source_text_hash": _hash_text(source_text, "source_text"),
  124. "candidate_hash": _digest(candidate_hash, "candidate_hash"),
  125. "rule_spec_hash": rule_spec_hash(spec),
  126. "model_hash": _digest(model_hash, "model_hash"),
  127. "prompt_hash": _digest(prompt_hash, "prompt_hash"),
  128. "context_hash": _digest(context_hash, "context_hash"),
  129. "issued_at": int(issued.timestamp()),
  130. "expires_at": int(expires_at.timestamp()),
  131. }
  132. class GenerationReceiptSigner:
  133. """Issue and verify compact HMAC receipts without exposing the secret."""
  134. def __init__(
  135. self,
  136. secret: str | bytes,
  137. *,
  138. clock=None,
  139. max_ttl_seconds: int = 600,
  140. clock_skew_seconds: int = 30,
  141. ):
  142. secret_bytes = (
  143. secret.encode("utf-8") if isinstance(secret, str) else bytes(secret)
  144. )
  145. if len(secret_bytes) < 16:
  146. raise ValueError("generation receipt secret is too short")
  147. if (
  148. isinstance(max_ttl_seconds, bool)
  149. or not isinstance(max_ttl_seconds, int)
  150. or max_ttl_seconds < 1
  151. or max_ttl_seconds > 3600
  152. ):
  153. raise ValueError("generation receipt max TTL is invalid")
  154. if (
  155. isinstance(clock_skew_seconds, bool)
  156. or not isinstance(clock_skew_seconds, int)
  157. or clock_skew_seconds < 0
  158. or clock_skew_seconds > 300
  159. ):
  160. raise ValueError("generation receipt clock skew is invalid")
  161. self._secret = secret_bytes
  162. self._clock = clock or (lambda: datetime.now(UTC))
  163. self.max_ttl_seconds = max_ttl_seconds
  164. self.clock_skew_seconds = clock_skew_seconds
  165. def issue(self, claims: dict[str, Any]) -> str:
  166. normalized = self._validate_claims(claims)
  167. self._validate_time(normalized, self._clock())
  168. payload = _b64encode(_canonical(normalized))
  169. signature = hmac.new(
  170. self._secret, payload.encode("ascii"), hashlib.sha256
  171. ).digest()
  172. return f"{payload}.{_b64encode(signature)}"
  173. def verify(
  174. self,
  175. receipt: str,
  176. *,
  177. actor_uid: str,
  178. source_text: str,
  179. rule_spec: dict[str, Any],
  180. now: datetime | None = None,
  181. allow_expired: bool = False,
  182. ) -> dict[str, Any]:
  183. if not isinstance(receipt, str) or len(receipt) > 4096:
  184. raise ValueError("generation receipt is invalid")
  185. try:
  186. payload, encoded_signature = receipt.split(".", 1)
  187. except ValueError as exc:
  188. raise ValueError("generation receipt is invalid") from exc
  189. expected = hmac.new(
  190. self._secret, payload.encode("ascii"), hashlib.sha256
  191. ).digest()
  192. provided = _b64decode(encoded_signature)
  193. if not hmac.compare_digest(expected, provided):
  194. raise ValueError("generation receipt signature is invalid")
  195. try:
  196. decoded = json.loads(_b64decode(payload))
  197. except (UnicodeDecodeError, json.JSONDecodeError) as exc:
  198. raise ValueError("generation receipt payload is invalid") from exc
  199. claims = self._validate_claims(decoded)
  200. current = now or self._clock()
  201. if current.tzinfo is None:
  202. raise ValueError("generation receipt clock must be timezone aware")
  203. self._validate_time(
  204. claims,
  205. current,
  206. allow_expired=allow_expired,
  207. )
  208. if claims["actor_uid"] != _uid(actor_uid, "actor_uid"):
  209. raise ValueError("generation receipt actor does not match")
  210. if claims["source_text_hash"] != _hash_text(
  211. source_text, "source_text"
  212. ):
  213. raise ValueError("generation receipt source does not match")
  214. if claims["rule_spec_hash"] != rule_spec_hash(
  215. validate_rule_spec(rule_spec)
  216. ):
  217. raise ValueError("generation receipt RuleSpec does not match")
  218. return claims
  219. def _validate_time(
  220. self,
  221. claims: dict[str, Any],
  222. current: datetime,
  223. *,
  224. allow_expired: bool = False,
  225. ) -> None:
  226. if not isinstance(current, datetime) or current.tzinfo is None:
  227. raise ValueError("generation receipt clock must be timezone aware")
  228. now = int(current.timestamp())
  229. if now >= claims["expires_at"] and not allow_expired:
  230. raise ValueError("generation receipt has expired")
  231. if claims["issued_at"] > now + self.clock_skew_seconds:
  232. raise ValueError("generation receipt issue time is in the future")
  233. if claims["expires_at"] <= claims["issued_at"]:
  234. raise ValueError("generation receipt expiry is invalid")
  235. if (
  236. claims["expires_at"] - claims["issued_at"]
  237. > self.max_ttl_seconds
  238. ):
  239. raise ValueError("generation receipt TTL exceeds the maximum")
  240. @staticmethod
  241. def _validate_claims(value: Any) -> dict[str, Any]:
  242. if not isinstance(value, dict) or set(value) != _RECEIPT_KEYS:
  243. raise ValueError("generation receipt claims are invalid")
  244. if value.get("version") != "1":
  245. raise ValueError("generation receipt version is unsupported")
  246. temporal = {}
  247. for key in ("issued_at", "expires_at"):
  248. item = value.get(key)
  249. if (
  250. isinstance(item, bool)
  251. or not isinstance(item, int)
  252. or item <= 0
  253. ):
  254. raise ValueError(
  255. f"generation receipt {key} is invalid"
  256. )
  257. temporal[key] = item
  258. return {
  259. "version": "1",
  260. "generation_run_id": _uid(
  261. value.get("generation_run_id"), "generation_run_id"
  262. ),
  263. "actor_uid": _uid(value.get("actor_uid"), "actor_uid"),
  264. "source_text_hash": _digest(
  265. value.get("source_text_hash"), "source_text_hash"
  266. ),
  267. "candidate_hash": _digest(
  268. value.get("candidate_hash"), "candidate_hash"
  269. ),
  270. "rule_spec_hash": _digest(
  271. value.get("rule_spec_hash"), "rule_spec_hash"
  272. ),
  273. "model_hash": _digest(value.get("model_hash"), "model_hash"),
  274. "prompt_hash": _digest(value.get("prompt_hash"), "prompt_hash"),
  275. "context_hash": _digest(
  276. value.get("context_hash"), "context_hash"
  277. ),
  278. **temporal,
  279. }
  280. def _normalize_compiled(value: Any) -> dict[str, Any]:
  281. if not isinstance(value, dict) or set(value) != _COMPILED_KEYS:
  282. raise ValueError("trusted compiler returned an invalid result")
  283. if value.get("backend") not in {"sql_pushdown", "polars_batch"}:
  284. raise ValueError("trusted compiler returned an unsupported backend")
  285. compiler_version = str(value.get("compiler_version") or "").strip()
  286. if not compiler_version or len(compiler_version) > 80:
  287. raise ValueError("trusted compiler version is invalid")
  288. if not isinstance(value.get("plan"), dict):
  289. raise ValueError("trusted compiler plan is invalid")
  290. for key in ("schema_hashes", "binding_hashes", "capabilities"):
  291. if not isinstance(value.get(key), dict):
  292. raise ValueError(f"trusted compiler {key} are invalid")
  293. return {
  294. **copy.deepcopy(value),
  295. "compiler_version": compiler_version,
  296. "plan_hash": _digest(value.get("plan_hash"), "plan_hash"),
  297. }
  298. def _normalize_test(value: Any, context: dict[str, Any]) -> dict[str, Any]:
  299. if not isinstance(value, dict) or set(value) != _TEST_KEYS:
  300. raise ValueError("trusted test runner returned an invalid result")
  301. if value.get("status") != "success":
  302. raise ValueError("trusted rule dry-run did not succeed")
  303. if value.get("test_kind") not in {"dry_run", "sample", "golden"}:
  304. raise ValueError("trusted rule test kind is invalid")
  305. _uid(value.get("run_id"), "test run_id")
  306. if (
  307. value.get("plan_hash") != context.get("plan_hash")
  308. or value.get("schema_hashes") != context.get("schema_hashes")
  309. or value.get("binding_hashes") != context.get("binding_hashes")
  310. ):
  311. raise ValueError("test evidence does not match the exact compiled plan")
  312. counts = value.get("counts")
  313. if not isinstance(counts, dict) or any(
  314. isinstance(item, bool) or not isinstance(item, int) or item < 0
  315. for item in counts.values()
  316. ):
  317. raise ValueError("trusted test counts are invalid")
  318. attestation = value.get("attestation")
  319. if not isinstance(attestation, dict):
  320. raise ValueError("trusted test attestation is invalid")
  321. return copy.deepcopy(value)
  322. class RulePublicationService:
  323. """Coordinate only server-generated compile and test evidence."""
  324. def __init__(
  325. self,
  326. repository,
  327. *,
  328. receipt_signer: GenerationReceiptSigner | None,
  329. compiler,
  330. test_runner,
  331. ):
  332. self.repository = repository
  333. self.receipt_signer = receipt_signer
  334. self.compiler = compiler
  335. self.test_runner = test_runner
  336. def create_draft(
  337. self,
  338. *,
  339. rule_spec: dict[str, Any],
  340. source_text: str,
  341. actor_uid: str,
  342. generation_receipt: str,
  343. category: str,
  344. source_language: str,
  345. generated_kind: str,
  346. ) -> dict[str, Any]:
  347. if self.receipt_signer is None:
  348. raise RuntimeError("generation receipt signer is not configured")
  349. spec = validate_rule_spec(rule_spec)
  350. actor = _uid(actor_uid, "actor_uid")
  351. claims = self.receipt_signer.verify(
  352. generation_receipt,
  353. actor_uid=actor,
  354. source_text=source_text,
  355. rule_spec=spec,
  356. allow_expired=True,
  357. )
  358. receipt_hash = hashlib.sha256(
  359. generation_receipt.encode("utf-8")
  360. ).hexdigest()
  361. return self.repository.create_draft_from_generation(
  362. rule_spec=spec,
  363. source_text=source_text,
  364. created_by=actor,
  365. category=category,
  366. source_language=source_language,
  367. generated_kind=generated_kind,
  368. receipt_claims=claims,
  369. receipt_hash=receipt_hash,
  370. )
  371. def validate(
  372. self,
  373. version_id: str,
  374. actor_uid: str,
  375. ) -> dict[str, Any]:
  376. version = _uid(version_id, "version_id")
  377. actor = _uid(actor_uid, "actor_uid")
  378. context = self.repository.load_logical_validation_context(
  379. version_id=version,
  380. )
  381. try:
  382. compiled = _normalize_compiled(self.compiler.compile(context))
  383. except ValueError as exc:
  384. self.repository.record_validation_failure(
  385. version_id=version,
  386. actor_uid=actor,
  387. error_code="deterministic_validation_error",
  388. error_hash=hashlib.sha256(str(exc).encode("utf-8")).hexdigest(),
  389. )
  390. raise RuleValidationRejected(str(exc)) from exc
  391. return self.repository.persist_logical_compile(
  392. version_id=version,
  393. actor_uid=actor,
  394. context=context,
  395. compiled=compiled,
  396. )
  397. def test(
  398. self,
  399. version_id: str,
  400. actor_uid: str,
  401. *,
  402. plan_id: str,
  403. ) -> dict[str, Any]:
  404. version = _uid(version_id, "version_id")
  405. actor = _uid(actor_uid, "actor_uid")
  406. plan = _uid(plan_id, "plan_id")
  407. context = self.repository.load_logical_test_context(
  408. version_id=version,
  409. plan_id=plan,
  410. )
  411. if "terminal_result" in context:
  412. return copy.deepcopy(context["terminal_result"])
  413. evidence = _normalize_test(self.test_runner.run(context), context)
  414. return self.repository.persist_logical_test(
  415. version_id=version,
  416. plan_id=plan,
  417. actor_uid=actor,
  418. context=context,
  419. evidence=evidence,
  420. )
  421. def publish(self, version_id: str, actor_uid: str) -> dict[str, Any]:
  422. return self.repository.publish_validated_version(
  423. version_id=_uid(version_id, "version_id"),
  424. actor_uid=_uid(actor_uid, "actor_uid"),
  425. )
  426. def evidence(self, version_id: str) -> dict[str, Any]:
  427. return self.repository.get_publication_evidence(
  428. version_id=_uid(version_id, "version_id")
  429. )
  430. def catalog(self, *, query: str = "", limit: int = 50):
  431. if not isinstance(query, str) or len(query) > 200:
  432. raise ValueError("catalog query is invalid")
  433. if (
  434. isinstance(limit, bool)
  435. or not isinstance(limit, int)
  436. or limit < 1
  437. or limit > 100
  438. ):
  439. raise ValueError("catalog limit is invalid")
  440. return self.repository.search_published_rules(
  441. query=query.strip(), limit=limit
  442. )
  443. class PhysicalPlanPublicationService:
  444. """Test and publish deployment-bound plans independently of RuleVersion."""
  445. def __init__(self, repository, *, test_runner):
  446. self.repository = repository
  447. self.test_runner = test_runner
  448. def validate(self, plan_id: str, actor_uid: str) -> dict[str, Any]:
  449. return self.repository.attest_physical_compile(
  450. plan_id=_uid(plan_id, "plan_id"),
  451. actor_uid=_uid(actor_uid, "actor_uid"),
  452. )
  453. def test(self, plan_id: str, actor_uid: str) -> dict[str, Any]:
  454. plan = _uid(plan_id, "plan_id")
  455. actor = _uid(actor_uid, "actor_uid")
  456. context = self.repository.load_physical_test_context(plan_id=plan)
  457. if "terminal_result" in context:
  458. return copy.deepcopy(context["terminal_result"])
  459. evidence = _normalize_test(self.test_runner.run(context), context)
  460. return self.repository.persist_physical_test(
  461. plan_id=plan,
  462. actor_uid=actor,
  463. context=context,
  464. evidence=evidence,
  465. )
  466. def publish(self, plan_id: str, actor_uid: str) -> dict[str, Any]:
  467. return self.repository.publish_tested_physical_plan(
  468. plan_id=_uid(plan_id, "plan_id"),
  469. actor_uid=_uid(actor_uid, "actor_uid"),
  470. )
  471. class CanonicalBoundCompiler:
  472. """Compile a repository-loaded physical context through the registry."""
  473. def __init__(self, compiler_registry):
  474. self.compiler_registry = compiler_registry
  475. def compile(self, context: dict[str, Any]) -> dict[str, Any]:
  476. if not isinstance(context, dict):
  477. raise ValueError("canonical publication context is invalid")
  478. try:
  479. rule_version = context["rule_version"]
  480. input_schema = context["input_schema"]
  481. output_schema = context["output_schema"]
  482. input_binding = context["input_binding"]
  483. output_binding = context["output_binding"]
  484. backend = context["backend"]
  485. except KeyError as exc:
  486. raise ValueError(
  487. "canonical publication context is incomplete"
  488. ) from exc
  489. compiler = self.compiler_registry.select(
  490. rule_version.get("rule_spec"),
  491. input_binding,
  492. output_binding,
  493. )
  494. compiled = compiler.compile(
  495. rule_version=rule_version,
  496. input_schema=input_schema,
  497. output_schema=output_schema,
  498. input_binding=input_binding,
  499. output_binding=output_binding,
  500. backend=backend,
  501. )
  502. plan = compiled.get("plan")
  503. if not isinstance(plan, dict):
  504. raise ValueError("trusted compiler returned an invalid plan")
  505. return {
  506. "backend": compiled.get("backend"),
  507. "compiler_version": compiled.get("compiler_version"),
  508. "plan": plan,
  509. "plan_hash": compiled.get("plan_hash"),
  510. "schema_hashes": {
  511. "rule_spec_hash": rule_version.get("spec_hash"),
  512. "input_schema_snapshot_id": input_schema.get("id"),
  513. "input_schema_hash": input_schema.get("schema_hash"),
  514. "output_schema_snapshot_id": output_schema.get("id"),
  515. "output_schema_hash": output_schema.get("schema_hash"),
  516. },
  517. "binding_hashes": {
  518. "input": input_binding.get("binding_hash"),
  519. "output": output_binding.get("binding_hash"),
  520. },
  521. "capabilities": copy.deepcopy(backend),
  522. }
  523. class LogicalRuleCompiler:
  524. """Compile a draft RuleSpec against its immutable validation profile."""
  525. VERSION = "dataops-logical-rule-1.0"
  526. def compile(self, context: dict[str, Any]) -> dict[str, Any]:
  527. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  528. from app.core.data_rules.contracts import read_rule_spec
  529. if not isinstance(context, dict):
  530. raise ValueError("logical validation context is invalid")
  531. version = context.get("version")
  532. profile = context.get("profile")
  533. if not isinstance(version, dict) or version.get("status") != "draft":
  534. raise ValueError("only draft rule versions may be validated")
  535. if not isinstance(profile, dict):
  536. raise ValueError("trusted validation profile is unavailable")
  537. spec = read_rule_spec(version.get("rule_spec"))
  538. if rule_spec_hash(spec) != version.get("spec_hash"):
  539. raise ValueError("canonical RuleSpec hash does not match")
  540. snapshots = {}
  541. for prefix in ("input", "output"):
  542. fields = profile.get(f"{prefix}_fields")
  543. if not isinstance(fields, list) or not fields:
  544. raise ValueError(
  545. f"trusted {prefix} validation fields are unavailable"
  546. )
  547. snapshots[prefix] = {
  548. "id": _uid(
  549. profile.get(f"{prefix}_schema_snapshot_id"),
  550. f"{prefix}_schema_snapshot_id",
  551. ),
  552. "schema_ref": str(
  553. profile.get(f"{prefix}_schema_ref") or ""
  554. ),
  555. "schema_hash": _digest(
  556. profile.get(f"{prefix}_schema_hash"),
  557. f"{prefix}_schema_hash",
  558. ),
  559. "fields": copy.deepcopy(fields),
  560. "source_revision": str(
  561. profile.get(f"{prefix}_source_revision") or ""
  562. ),
  563. }
  564. if (
  565. spec["input_schema_ref"] != snapshots["input"]["schema_ref"]
  566. or spec["output_schema_ref"]
  567. != snapshots["output"]["schema_ref"]
  568. ):
  569. raise ValueError(
  570. "RuleSpec schema references do not match validation profile"
  571. )
  572. version_id = _uid(version.get("id"), "version_id")
  573. profile_id = _uid(profile.get("id"), "validation_profile_id")
  574. binding_base = {
  575. "data_source_uid": version_id,
  576. "object_kind": "parquet_artifact",
  577. "dialect": "polars",
  578. "write_mode": "append",
  579. }
  580. compiled = PolarsRuleCompiler().compile(
  581. rule_version={**version, "status": "published"},
  582. input_schema=snapshots["input"],
  583. output_schema=snapshots["output"],
  584. input_binding={
  585. "id": profile_id,
  586. **binding_base,
  587. "schema_snapshot_id": snapshots["input"]["id"],
  588. "access_mode": "read",
  589. "object_ref": "validation-input",
  590. },
  591. output_binding={
  592. "id": version_id,
  593. **binding_base,
  594. "schema_snapshot_id": snapshots["output"]["id"],
  595. "access_mode": "write",
  596. "object_ref": "validation-output",
  597. },
  598. backend={
  599. "max_rows": 100_000,
  600. "max_artifact_bytes": 32 * 1024 * 1024,
  601. "memory_limit_bytes": 256 * 1024 * 1024,
  602. "masking_policies": {
  603. "customer_mobile_last4": "preserve_last_4",
  604. "redact": "redact",
  605. },
  606. "lookup_bindings": {},
  607. },
  608. )
  609. plan = compiled["plan"]
  610. return {
  611. "backend": "polars_batch",
  612. "compiler_version": compiled["compiler_version"],
  613. "plan": plan,
  614. "plan_hash": compiled["plan_hash"],
  615. "schema_hashes": {
  616. "input": snapshots["input"]["schema_hash"],
  617. "output": snapshots["output"]["schema_hash"],
  618. },
  619. "binding_hashes": {},
  620. "capabilities": {
  621. "supported_execution_backends": ["polars"],
  622. "closed_rulespec": "2.0",
  623. "resource_limits": plan["resource_limits"],
  624. },
  625. }
  626. class ServerOwnedLogicalDryRunRunner:
  627. """Execute a logical Polars plan on a bounded server-owned artifact."""
  628. def __init__(self, artifact_store):
  629. self.artifact_store = artifact_store
  630. def run(self, context: dict[str, Any]) -> dict[str, Any]:
  631. from app.core.data_rules.compilers.polars import (
  632. validate_bound_polars_plan,
  633. )
  634. from app.runner.polars_worker import execute_isolated_polars_plan
  635. sample_ref = context.get("input_sample_artifact_ref")
  636. if not isinstance(sample_ref, str) or not sample_ref:
  637. raise ValueError("server-owned sample artifact is required")
  638. sample_digest = _digest(
  639. context.get("input_sample_artifact_digest"),
  640. "input_sample_artifact_digest",
  641. )
  642. plan = validate_bound_polars_plan(context.get("plan"))
  643. described = self.artifact_store.describe(sample_ref)
  644. if (
  645. described["digest"] != sample_digest
  646. or described["schema_hash"] != plan["input_schema_hash"]
  647. ):
  648. raise ValueError("sample artifact schema has drifted")
  649. golden_ref = context.get("golden_output_artifact_ref")
  650. golden_digest = context.get("golden_output_artifact_digest")
  651. if (golden_ref is None) != (golden_digest is None):
  652. raise ValueError("golden output artifact is incomplete")
  653. if golden_ref is not None:
  654. if not isinstance(golden_ref, str) or not golden_ref:
  655. raise ValueError("golden output artifact is invalid")
  656. golden_digest = _digest(
  657. golden_digest,
  658. "golden_output_artifact_digest",
  659. )
  660. output_path = None
  661. try:
  662. with ExitStack() as stack:
  663. input_path = stack.enter_context(
  664. self.artifact_store.stage(
  665. sample_ref,
  666. sample_digest,
  667. expected_schema_fields=plan["input_fields"],
  668. limits=plan["resource_limits"],
  669. )
  670. )
  671. golden_path = None
  672. if golden_ref is not None:
  673. golden_path = stack.enter_context(
  674. self.artifact_store.stage(
  675. golden_ref,
  676. golden_digest,
  677. expected_schema_fields=plan["output_fields"],
  678. limits=plan["resource_limits"],
  679. )
  680. )
  681. with tempfile.NamedTemporaryFile(
  682. prefix="dataops-logical-dry-run-",
  683. suffix=".parquet",
  684. delete=False,
  685. ) as handle:
  686. output_path = handle.name
  687. result = execute_isolated_polars_plan(
  688. {
  689. "plan": plan,
  690. "input_path": input_path,
  691. "lookup_paths": {},
  692. "output_path": output_path,
  693. "golden_path": golden_path,
  694. "golden_digest": golden_digest,
  695. "masking_policies": {
  696. "customer_mobile_last4": "preserve_last_4",
  697. "redact": "redact",
  698. },
  699. },
  700. memory_limit_bytes=plan["resource_limits"][
  701. "memory_limit_bytes"
  702. ],
  703. )
  704. prepared = self.artifact_store.prepare_path(
  705. output_path,
  706. new_governance_uid(),
  707. 300,
  708. schema_fields=plan["output_fields"],
  709. limits=plan["resource_limits"],
  710. )
  711. compared_output_digest = result.get("output_file_digest")
  712. if (
  713. compared_output_digest is not None
  714. and prepared["digest"] != compared_output_digest
  715. ):
  716. raise ValueError(
  717. "golden-compared output artifact has drifted"
  718. )
  719. finally:
  720. if output_path is not None:
  721. with suppress(FileNotFoundError):
  722. os.unlink(output_path)
  723. count_keys = (
  724. "rows_in",
  725. "rows_out",
  726. "rows_rejected",
  727. "rows_quarantined",
  728. "rows_filtered",
  729. "rows_deduplicated",
  730. "rows_join_dropped",
  731. "rows_aggregated",
  732. "violation_count",
  733. )
  734. return {
  735. "status": "success",
  736. "test_kind": "dry_run",
  737. "run_id": new_governance_uid(),
  738. "plan_hash": context["plan_hash"],
  739. "schema_hashes": context["schema_hashes"],
  740. "binding_hashes": context["binding_hashes"],
  741. "counts": {key: int(result.get(key, 0)) for key in count_keys},
  742. "attestation": {
  743. "input_digest": described["digest"],
  744. "output_digest": prepared["digest"],
  745. "output_schema_hash": prepared["schema_hash"],
  746. "violation_digest": hashlib.sha256(
  747. _canonical(result.get("violations", []))
  748. ).hexdigest(),
  749. "golden_output_digest": result.get(
  750. "golden_output_digest"
  751. ),
  752. "golden_rows_compared": result.get(
  753. "golden_rows_compared"
  754. ),
  755. },
  756. }
  757. class ServerOwnedPhysicalPreflightRunner:
  758. """Execute an exact physical Polars plan on its latest trusted input."""
  759. def __init__(self, artifact_store, datasource_manager=None):
  760. self.artifact_store = artifact_store
  761. self.datasource_manager = datasource_manager
  762. def run(self, context: dict[str, Any]) -> dict[str, Any]:
  763. from app.core.data_rules.compilers.polars import (
  764. validate_bound_polars_plan,
  765. )
  766. from app.runner.polars_worker import execute_isolated_polars_plan
  767. if context.get("backend") == "sql_pushdown":
  768. return self._run_sql(context)
  769. if context.get("backend") != "polars_batch":
  770. raise ValueError("physical preflight backend is unsupported")
  771. sample = context.get("sample_artifact")
  772. if (
  773. not isinstance(sample, dict)
  774. or set(sample) != {
  775. "artifact_ref",
  776. "digest",
  777. "schema_fields",
  778. }
  779. ):
  780. raise ValueError("server-owned physical sample is required")
  781. plan = validate_bound_polars_plan(context.get("plan"))
  782. output_path = None
  783. try:
  784. with self.artifact_store.stage(
  785. sample["artifact_ref"],
  786. sample["digest"],
  787. expected_schema_fields=sample["schema_fields"],
  788. limits=plan["resource_limits"],
  789. ) as input_path:
  790. with tempfile.NamedTemporaryFile(
  791. prefix="dataops-physical-preflight-",
  792. suffix=".parquet",
  793. delete=False,
  794. ) as handle:
  795. output_path = handle.name
  796. result = execute_isolated_polars_plan(
  797. {
  798. "plan": plan,
  799. "input_path": input_path,
  800. "lookup_paths": {},
  801. "output_path": output_path,
  802. "masking_policies": {
  803. "customer_mobile_last4": "preserve_last_4",
  804. "redact": "redact",
  805. },
  806. },
  807. memory_limit_bytes=plan["resource_limits"][
  808. "memory_limit_bytes"
  809. ],
  810. )
  811. prepared = self.artifact_store.prepare_path(
  812. output_path,
  813. new_governance_uid(),
  814. 300,
  815. schema_fields=plan["output_fields"],
  816. limits=plan["resource_limits"],
  817. )
  818. finally:
  819. if output_path is not None:
  820. with suppress(FileNotFoundError):
  821. os.unlink(output_path)
  822. count_keys = (
  823. "rows_in",
  824. "rows_out",
  825. "rows_rejected",
  826. "rows_quarantined",
  827. "rows_filtered",
  828. "rows_deduplicated",
  829. "rows_join_dropped",
  830. "rows_aggregated",
  831. "violation_count",
  832. )
  833. return {
  834. "status": "success",
  835. "test_kind": "dry_run",
  836. "run_id": new_governance_uid(),
  837. "plan_hash": context["plan_hash"],
  838. "schema_hashes": context["schema_hashes"],
  839. "binding_hashes": context["binding_hashes"],
  840. "counts": {key: int(result.get(key, 0)) for key in count_keys},
  841. "attestation": {
  842. "input_digest": sample["digest"],
  843. "output_digest": prepared["digest"],
  844. "output_schema_hash": prepared["schema_hash"],
  845. "violation_digest": hashlib.sha256(
  846. _canonical(result.get("violations", []))
  847. ).hexdigest(),
  848. },
  849. }
  850. def _run_sql(self, context: dict[str, Any]) -> dict[str, Any]:
  851. from sqlalchemy import text
  852. from app.core.data_rules.compilers.sql import validate_bound_sql_plan
  853. if self.datasource_manager is None:
  854. raise ValueError(
  855. "server-owned SQL preflight executor is not configured"
  856. )
  857. plan = validate_bound_sql_plan(context.get("plan"))
  858. statement = plan["statements"][0]
  859. explain_sql = f"EXPLAIN {statement['sql']}"
  860. with self.datasource_manager.connect(
  861. plan["data_source_uid"], purpose="dataflow_read"
  862. ) as connection:
  863. rows = connection.execute(
  864. text(explain_sql), statement["parameters"]
  865. ).fetchall()
  866. if not rows:
  867. raise ValueError("physical SQL preflight returned no explain plan")
  868. explain_digest = hashlib.sha256(
  869. _canonical([list(row) for row in rows])
  870. ).hexdigest()
  871. return {
  872. "status": "success",
  873. "test_kind": "dry_run",
  874. "run_id": new_governance_uid(),
  875. "plan_hash": context["plan_hash"],
  876. "schema_hashes": context["schema_hashes"],
  877. "binding_hashes": context["binding_hashes"],
  878. "counts": {
  879. "rows_in": 0,
  880. "rows_out": 0,
  881. "rows_rejected": 0,
  882. },
  883. "attestation": {
  884. "dialect": plan["dialect"],
  885. "explain_digest": explain_digest,
  886. "statement_digest": hashlib.sha256(
  887. statement["sql"].encode("utf-8")
  888. ).hexdigest(),
  889. },
  890. }
  891. __all__ = [
  892. "CanonicalBoundCompiler",
  893. "GenerationReceiptSigner",
  894. "LogicalRuleCompiler",
  895. "PhysicalPlanPublicationService",
  896. "RulePublicationService",
  897. "RuleValidationRejected",
  898. "ServerOwnedLogicalDryRunRunner",
  899. "ServerOwnedPhysicalPreflightRunner",
  900. "generation_receipt_claims",
  901. ]