test_rule_polars.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800
  1. from __future__ import annotations
  2. import copy
  3. from datetime import date, datetime
  4. import polars as pl
  5. import pytest
  6. from app.core.common.identifiers import new_governance_uid
  7. from app.runner.nodes import NodeExecutionError
  8. from tests.core.data_rules.test_polars_compiler import (
  9. _backend,
  10. _binding,
  11. _published_rule,
  12. _schema,
  13. )
  14. from tests.runner.test_artifacts import FakeMinio
  15. def _plan_store(client):
  16. from app.runner.artifacts import ArtifactStore
  17. return ArtifactStore(
  18. client,
  19. bucket="dataops-rules",
  20. max_artifact_bytes=8 * 1024 * 1024,
  21. max_rows=10_000,
  22. memory_limit_bytes=256 * 1024 * 1024,
  23. max_ttl_seconds=3600,
  24. )
  25. class Resolver:
  26. def __init__(self, values, artifact_store):
  27. self.values = values
  28. self.artifact_store = artifact_store
  29. self.calls = []
  30. self.events = []
  31. self.attest_error = None
  32. self.publish_error = None
  33. self.registrations = []
  34. self.handoffs = {}
  35. def resolve(self, *, binding_id, correlation_id, kind):
  36. self.calls.append((binding_id, correlation_id))
  37. self.events.append(("resolve", binding_id, kind))
  38. return self.values[binding_id]
  39. def attest_binding(self, *, binding_id, binding_hash, access_mode):
  40. self.events.append(("attest", binding_id, binding_hash, access_mode))
  41. if self.attest_error is not None:
  42. raise self.attest_error
  43. return {"binding_hash": binding_hash}
  44. def resolve_handoff(self, *, artifact_ref, correlation_id):
  45. self.events.append(("handoff", artifact_ref, correlation_id))
  46. return self.handoffs[artifact_ref]
  47. def publish_path(
  48. self,
  49. path,
  50. *,
  51. binding_id,
  52. correlation_id,
  53. kind,
  54. binding_hash,
  55. ttl_seconds,
  56. schema_fields,
  57. limits,
  58. ):
  59. self.events.append(("publish", binding_id, kind))
  60. if self.publish_error is not None:
  61. raise self.publish_error
  62. artifact = self.artifact_store.write_path(
  63. path,
  64. correlation_id,
  65. ttl_seconds,
  66. schema_fields=schema_fields,
  67. limits=limits,
  68. )
  69. self.registrations.append(
  70. {
  71. "binding_id": binding_id,
  72. "correlation_id": correlation_id,
  73. "artifact": artifact,
  74. "kind": kind,
  75. "binding_hash": binding_hash,
  76. }
  77. )
  78. return artifact
  79. def _compiled_plan(steps):
  80. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  81. fields = [
  82. ("customer_id", "integer", False),
  83. ("name", "string", True),
  84. ("mobile", "string", True),
  85. ]
  86. input_schema = _schema("bd:customer:raw", fields)
  87. output_schema = _schema("bd:customer:clean", fields)
  88. input_binding = _binding(input_schema, access_mode="read")
  89. output_binding = _binding(output_schema, access_mode="write")
  90. rule = _published_rule(input_schema, output_schema, steps)
  91. compiled = PolarsRuleCompiler().compile(
  92. rule_version=rule,
  93. input_schema=input_schema,
  94. output_schema=output_schema,
  95. input_binding=input_binding,
  96. output_binding=output_binding,
  97. backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
  98. )
  99. return compiled, input_binding
  100. def _node(compiled):
  101. return {
  102. "id": "task5_polars",
  103. "type": "rule.apply",
  104. "purpose": "write",
  105. "idempotency": {
  106. "strategy": "deduplication_key",
  107. "key": "customer_id",
  108. },
  109. "config": {
  110. "component_binding_id": new_governance_uid(),
  111. "rule_version_id": compiled["plan"]["rule_version_id"],
  112. "execution_plan_hash": compiled["plan_hash"],
  113. },
  114. }
  115. def test_polars_adapter_reconstructs_assert_and_deduplicate_and_writes_artifact():
  116. from app.runner.rule_polars import PolarsRulePlanAdapter
  117. compiled, input_binding = _compiled_plan(
  118. [
  119. {
  120. "id": "trim_name",
  121. "op": "normalize_text",
  122. "column": "name",
  123. "trim": True,
  124. },
  125. {
  126. "id": "mobile_format",
  127. "op": "assert",
  128. "expression": "matches(mobile, '^[0-9]{11}$')",
  129. "on_failure": "reject",
  130. "severity": "error",
  131. },
  132. {
  133. "id": "one_customer",
  134. "op": "deduplicate",
  135. "keys": ["customer_id"],
  136. "order_by": ["name"],
  137. "keep": "first",
  138. },
  139. ]
  140. )
  141. store = _plan_store(FakeMinio())
  142. correlation_id = new_governance_uid()
  143. source = store.write(
  144. pl.DataFrame(
  145. {
  146. "customer_id": [1, 1, 2],
  147. "name": [" Alice ", "Alice B", " Bad "],
  148. "mobile": ["13800138000", "13800138000", "invalid"],
  149. }
  150. ).lazy(),
  151. correlation_id,
  152. 600,
  153. schema_fields=compiled["plan"]["input_fields"],
  154. limits=compiled["plan"]["resource_limits"],
  155. )
  156. resolver = Resolver(
  157. {
  158. input_binding["id"]: {
  159. **source,
  160. "binding_hash": compiled["plan"]["input_binding_hash"],
  161. }
  162. },
  163. store,
  164. )
  165. adapter = PolarsRulePlanAdapter(
  166. artifact_store=store,
  167. artifact_resolver=resolver,
  168. masking_policies={
  169. "customer_mobile_last4": "preserve_last_4"
  170. },
  171. artifact_ttl_seconds=300,
  172. )
  173. result = adapter.execute(
  174. plan=compiled["plan"],
  175. node=_node(compiled),
  176. parameters={},
  177. write_authorized=True,
  178. correlation_id=correlation_id,
  179. )
  180. assert result["rows_in"] == 3
  181. assert result["rows_out"] == 1
  182. assert result["rows_rejected"] == 1
  183. assert result["rows_filtered"] == 0
  184. assert result["rows_deduplicated"] == 1
  185. assert result["rows_join_dropped"] == 0
  186. assert result["rows_aggregated"] == 0
  187. assert result["violation_count"] == 1
  188. assert result["violations"] == [
  189. {"step_id": "mobile_format", "count": 1}
  190. ]
  191. assert len(result["_violation_sample"]) == 1
  192. assert result["_violation_sample"][0]["mobile"] == "[REDACTED]"
  193. assert result["commit_outcome"] == "committed"
  194. assert "schema_fields" not in result
  195. assert resolver.events[0] == (
  196. "attest",
  197. compiled["plan"]["output_binding_id"],
  198. compiled["plan"]["output_binding_hash"],
  199. "write",
  200. )
  201. assert resolver.registrations[0]["kind"] == "output"
  202. assert store.read(
  203. result["artifact_ref"],
  204. result["digest"],
  205. expected_schema_fields=compiled["plan"]["output_fields"],
  206. limits=compiled["plan"]["resource_limits"],
  207. ).collect().to_dicts() == [
  208. {
  209. "customer_id": 1,
  210. "name": "Alice",
  211. "mobile": "13800138000",
  212. }
  213. ]
  214. def test_polars_adapter_reports_unknown_catalog_commit_outcome():
  215. from app.runner.artifacts import ArtifactCommitUnknown
  216. from app.runner.rule_polars import PolarsRulePlanAdapter
  217. compiled, input_binding = _compiled_plan(
  218. [
  219. {
  220. "id": "trim_name",
  221. "op": "normalize_text",
  222. "column": "name",
  223. "trim": True,
  224. }
  225. ]
  226. )
  227. store = _plan_store(FakeMinio())
  228. correlation_id = new_governance_uid()
  229. source = store.write(
  230. pl.DataFrame(
  231. {"customer_id": [1], "name": [" A "], "mobile": ["1"]}
  232. ),
  233. correlation_id,
  234. 600,
  235. schema_fields=compiled["plan"]["input_fields"],
  236. limits=compiled["plan"]["resource_limits"],
  237. )
  238. resolver = Resolver(
  239. {
  240. input_binding["id"]: {
  241. **source,
  242. "binding_hash": compiled["plan"]["input_binding_hash"],
  243. }
  244. },
  245. store,
  246. )
  247. resolver.publish_error = ArtifactCommitUnknown("lost acknowledgement")
  248. with pytest.raises(NodeExecutionError, match="commit outcome") as error:
  249. PolarsRulePlanAdapter(
  250. artifact_store=store,
  251. artifact_resolver=resolver,
  252. ).execute(
  253. plan=compiled["plan"],
  254. node=_node(compiled),
  255. parameters={},
  256. write_authorized=True,
  257. correlation_id=correlation_id,
  258. )
  259. assert error.value.commit_outcome == "unknown"
  260. def test_polars_adapter_consumes_only_attested_upstream_artifact_ref():
  261. from app.runner.rule_polars import PolarsRulePlanAdapter
  262. compiled, _input_binding = _compiled_plan(
  263. [
  264. {
  265. "id": "trim_name",
  266. "op": "normalize_text",
  267. "column": "name",
  268. "trim": True,
  269. }
  270. ]
  271. )
  272. store = _plan_store(FakeMinio())
  273. correlation_id = new_governance_uid()
  274. source = store.write(
  275. pl.DataFrame(
  276. {
  277. "customer_id": [1],
  278. "name": ["Alice"],
  279. "mobile": ["13800138000"],
  280. }
  281. ),
  282. correlation_id,
  283. 600,
  284. schema_fields=compiled["plan"]["input_fields"],
  285. limits=compiled["plan"]["resource_limits"],
  286. )
  287. resolver = Resolver({}, store)
  288. resolver.handoffs[source["artifact_ref"]] = source
  289. adapter = PolarsRulePlanAdapter(
  290. artifact_store=store,
  291. artifact_resolver=resolver,
  292. artifact_ttl_seconds=300,
  293. )
  294. result = adapter.execute(
  295. plan=compiled["plan"],
  296. node=_node(compiled),
  297. parameters={"input_artifact": source["artifact_ref"]},
  298. write_authorized=True,
  299. correlation_id=correlation_id,
  300. )
  301. assert result["rows_out"] == 1
  302. assert (
  303. "handoff",
  304. source["artifact_ref"],
  305. correlation_id,
  306. ) in resolver.events
  307. with pytest.raises(NodeExecutionError, match="only one artifact"):
  308. adapter.execute(
  309. plan=compiled["plan"],
  310. node=_node(compiled),
  311. parameters={"rows": [{"customer_id": 1}]},
  312. write_authorized=True,
  313. correlation_id=correlation_id,
  314. )
  315. def test_polars_adapter_fails_closed_for_plan_hash_binding_and_authorization():
  316. from app.runner.rule_polars import PolarsRulePlanAdapter
  317. compiled, input_binding = _compiled_plan(
  318. [
  319. {
  320. "id": "trim_name",
  321. "op": "normalize_text",
  322. "column": "name",
  323. "trim": True,
  324. }
  325. ]
  326. )
  327. store = _plan_store(FakeMinio())
  328. correlation_id = new_governance_uid()
  329. source = store.write(
  330. pl.DataFrame(
  331. {"customer_id": [1], "name": [" A "], "mobile": ["1"]}
  332. ).lazy(),
  333. correlation_id,
  334. 600,
  335. schema_fields=compiled["plan"]["input_fields"],
  336. limits=compiled["plan"]["resource_limits"],
  337. )
  338. resolver = Resolver(
  339. {
  340. input_binding["id"]: {
  341. **source,
  342. "binding_hash": "0" * 64,
  343. }
  344. },
  345. store,
  346. )
  347. adapter = PolarsRulePlanAdapter(
  348. artifact_store=store,
  349. artifact_resolver=resolver,
  350. )
  351. node = _node(compiled)
  352. with pytest.raises(NodeExecutionError, match="authorization"):
  353. adapter.execute(
  354. plan=compiled["plan"],
  355. node=node,
  356. parameters={},
  357. write_authorized=False,
  358. correlation_id=correlation_id,
  359. )
  360. with pytest.raises(NodeExecutionError, match="binding"):
  361. adapter.execute(
  362. plan=compiled["plan"],
  363. node=node,
  364. parameters={},
  365. write_authorized=True,
  366. correlation_id=correlation_id,
  367. )
  368. resolver.values[input_binding["id"]]["binding_hash"] = compiled["plan"][
  369. "input_binding_hash"
  370. ]
  371. node["config"]["execution_plan_hash"] = "0" * 64
  372. with pytest.raises(NodeExecutionError, match="hash"):
  373. adapter.execute(
  374. plan=compiled["plan"],
  375. node=node,
  376. parameters={},
  377. write_authorized=True,
  378. correlation_id=correlation_id,
  379. )
  380. tampered = copy.deepcopy(compiled["plan"])
  381. tampered["operations"][0]["callable"] = "unsafe"
  382. node["config"]["execution_plan_hash"] = compiled["plan_hash"]
  383. with pytest.raises(NodeExecutionError, match="invalid"):
  384. adapter.execute(
  385. plan=tampered,
  386. node=node,
  387. parameters={},
  388. write_authorized=True,
  389. correlation_id=correlation_id,
  390. )
  391. def test_polars_adapter_attests_current_output_binding_before_reading_input():
  392. from app.runner.rule_polars import PolarsRulePlanAdapter
  393. compiled, input_binding = _compiled_plan(
  394. [
  395. {
  396. "id": "trim_name",
  397. "op": "normalize_text",
  398. "column": "name",
  399. "trim": True,
  400. }
  401. ]
  402. )
  403. store = _plan_store(FakeMinio())
  404. correlation_id = new_governance_uid()
  405. source = store.write(
  406. pl.DataFrame(
  407. {"customer_id": [1], "name": [" A "], "mobile": ["1"]}
  408. ),
  409. correlation_id,
  410. 600,
  411. schema_fields=compiled["plan"]["input_fields"],
  412. limits=compiled["plan"]["resource_limits"],
  413. )
  414. resolver = Resolver(
  415. {
  416. input_binding["id"]: {
  417. **source,
  418. "binding_hash": compiled["plan"]["input_binding_hash"],
  419. }
  420. },
  421. store,
  422. )
  423. resolver.attest_error = ValueError("binding changed")
  424. reads_before_execute = list(store.client.get_calls)
  425. with pytest.raises(NodeExecutionError, match="output binding"):
  426. PolarsRulePlanAdapter(
  427. artifact_store=store,
  428. artifact_resolver=resolver,
  429. ).execute(
  430. plan=compiled["plan"],
  431. node=_node(compiled),
  432. parameters={},
  433. write_authorized=True,
  434. correlation_id=correlation_id,
  435. )
  436. assert resolver.events == [
  437. (
  438. "attest",
  439. compiled["plan"]["output_binding_id"],
  440. compiled["plan"]["output_binding_hash"],
  441. "write",
  442. )
  443. ]
  444. assert store.client.get_calls == reads_before_execute
  445. def test_polars_adapter_uses_exact_decimal_and_timestamptz_output_contracts():
  446. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  447. from app.core.data_rules.execution_contracts import canonical_schema_hash
  448. from app.runner.rule_polars import PolarsRulePlanAdapter
  449. input_schema = _schema(
  450. "bd:payment:raw",
  451. [
  452. ("amount", "string", False),
  453. ("occurred_at", "string", False),
  454. ],
  455. )
  456. output_schema = _schema(
  457. "bd:payment:clean",
  458. [
  459. ("amount", "decimal", False),
  460. ("occurred_at", "timestamptz", False),
  461. ],
  462. )
  463. output_schema["fields"][0].update({"precision": 12, "scale": 2})
  464. output_schema["fields"][1]["timezone"] = "Asia/Shanghai"
  465. output_schema["schema_hash"] = canonical_schema_hash(
  466. output_schema["fields"]
  467. )
  468. source_binding = _binding(input_schema, access_mode="read")
  469. output_binding = _binding(output_schema, access_mode="write")
  470. rule = _published_rule(
  471. input_schema,
  472. output_schema,
  473. [
  474. {
  475. "id": "cast_amount",
  476. "op": "cast",
  477. "column": "amount",
  478. "to": "decimal",
  479. "on_error": "fail",
  480. },
  481. {
  482. "id": "cast_time",
  483. "op": "cast",
  484. "column": "occurred_at",
  485. "to": "timestamptz",
  486. "on_error": "fail",
  487. },
  488. ],
  489. )
  490. compiled = PolarsRuleCompiler().compile(
  491. rule_version=rule,
  492. input_schema=input_schema,
  493. output_schema=output_schema,
  494. input_binding=source_binding,
  495. output_binding=output_binding,
  496. backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
  497. )
  498. store = _plan_store(FakeMinio())
  499. correlation_id = new_governance_uid()
  500. source = store.write(
  501. pl.DataFrame(
  502. {
  503. "amount": ["12.34"],
  504. "occurred_at": ["2026-07-23T12:30:00+08:00"],
  505. }
  506. ),
  507. correlation_id,
  508. 600,
  509. schema_fields=compiled["plan"]["input_fields"],
  510. limits=compiled["plan"]["resource_limits"],
  511. )
  512. resolver = Resolver(
  513. {
  514. source_binding["id"]: {
  515. **source,
  516. "binding_hash": compiled["plan"]["input_binding_hash"],
  517. }
  518. },
  519. store,
  520. )
  521. result = PolarsRulePlanAdapter(
  522. artifact_store=store,
  523. artifact_resolver=resolver,
  524. ).execute(
  525. plan=compiled["plan"],
  526. node=_node(compiled),
  527. parameters={},
  528. write_authorized=True,
  529. correlation_id=correlation_id,
  530. )
  531. output = store.read(
  532. result["artifact_ref"],
  533. result["digest"],
  534. expected_schema_fields=compiled["plan"]["output_fields"],
  535. limits=compiled["plan"]["resource_limits"],
  536. ).collect()
  537. assert output.schema["amount"] == pl.Decimal(precision=12, scale=2)
  538. assert output.schema["occurred_at"] == pl.Datetime(
  539. time_zone="Asia/Shanghai"
  540. )
  541. def test_polars_expression_date_and_timestamp_use_plan_timezone():
  542. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  543. from app.runner.rule_polars import PolarsRulePlanAdapter
  544. input_schema = _schema(
  545. "bd:event:raw",
  546. [
  547. ("raw_time", "string", False),
  548. ("cast_time", "string", False),
  549. ],
  550. )
  551. output_schema = _schema(
  552. "bd:event:clean",
  553. [
  554. ("raw_time", "string", False),
  555. ("cast_time", "timestamp", False),
  556. ("local_date", "date", False),
  557. ("local_time", "timestamp", False),
  558. ],
  559. )
  560. source_binding = _binding(input_schema, access_mode="read")
  561. output_binding = _binding(output_schema, access_mode="write")
  562. rule = _published_rule(
  563. input_schema,
  564. output_schema,
  565. [
  566. {
  567. "id": "cast_timestamp",
  568. "op": "cast",
  569. "column": "cast_time",
  570. "to": "timestamp",
  571. "on_error": "fail",
  572. },
  573. {
  574. "id": "derive_date",
  575. "op": "derive",
  576. "target": "local_date",
  577. "expression": "date(raw_time)",
  578. },
  579. {
  580. "id": "derive_timestamp",
  581. "op": "derive",
  582. "target": "local_time",
  583. "expression": "timestamp(raw_time)",
  584. },
  585. ],
  586. )
  587. compiled = PolarsRuleCompiler().compile(
  588. rule_version=rule,
  589. input_schema=input_schema,
  590. output_schema=output_schema,
  591. input_binding=source_binding,
  592. output_binding=output_binding,
  593. backend=_backend(memory_limit_bytes=128 * 1024 * 1024),
  594. )
  595. store = _plan_store(FakeMinio())
  596. correlation_id = new_governance_uid()
  597. source = store.write(
  598. pl.DataFrame(
  599. {
  600. "raw_time": ["2026-07-22T16:30:00+00:00"],
  601. "cast_time": ["2026-07-22T16:30:00+00:00"],
  602. }
  603. ),
  604. correlation_id,
  605. 600,
  606. schema_fields=compiled["plan"]["input_fields"],
  607. limits=compiled["plan"]["resource_limits"],
  608. )
  609. resolver = Resolver(
  610. {
  611. source_binding["id"]: {
  612. **source,
  613. "binding_hash": compiled["plan"]["input_binding_hash"],
  614. }
  615. },
  616. store,
  617. )
  618. result = PolarsRulePlanAdapter(
  619. artifact_store=store,
  620. artifact_resolver=resolver,
  621. ).execute(
  622. plan=compiled["plan"],
  623. node=_node(compiled),
  624. parameters={},
  625. write_authorized=True,
  626. correlation_id=correlation_id,
  627. )
  628. output = store.read(
  629. result["artifact_ref"],
  630. result["digest"],
  631. expected_schema_fields=compiled["plan"]["output_fields"],
  632. limits=compiled["plan"]["resource_limits"],
  633. ).collect()
  634. assert output["local_date"].item() == date(2026, 7, 23)
  635. assert output["local_time"].item() == datetime(2026, 7, 23, 0, 30)
  636. assert output["cast_time"].item() == datetime(2026, 7, 23, 0, 30)
  637. assert output.schema["local_time"] == pl.Datetime
  638. def test_rule_executor_attests_polars_canonical_hashes_and_forwards_correlation():
  639. from app.runner.rules import RulePlanExecutor
  640. compiled, _input_binding = _compiled_plan(
  641. [
  642. {
  643. "id": "trim_name",
  644. "op": "normalize_text",
  645. "column": "name",
  646. "trim": True,
  647. }
  648. ]
  649. )
  650. node = _node(compiled)
  651. correlation_id = new_governance_uid()
  652. plan = compiled["plan"]
  653. record = {
  654. "component_binding_id": node["config"]["component_binding_id"],
  655. "rule_version_id": plan["rule_version_id"],
  656. "backend": "polars_batch",
  657. "compiler_version": compiled["compiler_version"],
  658. "plan": plan,
  659. "plan_hash": compiled["plan_hash"],
  660. "schema_hashes": {
  661. "rule_spec_hash": plan["rule_spec_hash"],
  662. "input_schema_snapshot_id": plan["input_schema_snapshot_id"],
  663. "input_schema_hash": plan["input_schema_hash"],
  664. "output_schema_snapshot_id": plan["output_schema_snapshot_id"],
  665. "output_schema_hash": plan["output_schema_hash"],
  666. },
  667. "canonical_rule_spec_hash": plan["rule_spec_hash"],
  668. "canonical_input_schema_snapshot_id": plan[
  669. "input_schema_snapshot_id"
  670. ],
  671. "canonical_input_schema_hash": plan["input_schema_hash"],
  672. "canonical_input_binding_hash": plan["input_binding_hash"],
  673. "canonical_input_object_kind": "parquet_artifact",
  674. "canonical_output_schema_snapshot_id": plan[
  675. "output_schema_snapshot_id"
  676. ],
  677. "canonical_output_schema_hash": plan["output_schema_hash"],
  678. "canonical_output_binding_hash": plan["output_binding_hash"],
  679. "canonical_output_object_kind": "parquet_artifact",
  680. "plan_status": "published",
  681. "rule_status": "published",
  682. "publication_audit_trusted": True,
  683. "logical_evidence_trusted": True,
  684. "physical_evidence_trusted": True,
  685. "component_kind": "rule.apply",
  686. "binding_idempotency": node["idempotency"],
  687. }
  688. class Repository:
  689. def load(self, **_kwargs):
  690. return record
  691. class Adapter:
  692. def __init__(self):
  693. self.kwargs = None
  694. def execute(self, **kwargs):
  695. self.kwargs = kwargs
  696. return {"rows_in": 1, "rows_out": 1, "rows_rejected": 0}
  697. adapter = Adapter()
  698. result = RulePlanExecutor(
  699. Repository(), adapters={"polars_batch": adapter}
  700. ).execute(
  701. node,
  702. {},
  703. write_authorized=True,
  704. correlation_id=correlation_id,
  705. )
  706. assert result["rows_out"] == 1
  707. assert adapter.kwargs["correlation_id"] == correlation_id
  708. record["canonical_input_schema_hash"] = "0" * 64
  709. with pytest.raises(NodeExecutionError, match="attestation"):
  710. RulePlanExecutor(
  711. Repository(), adapters={"polars_batch": adapter}
  712. ).execute(
  713. node,
  714. {},
  715. write_authorized=True,
  716. correlation_id=correlation_id,
  717. )
  718. def test_node_registry_forwards_trusted_correlation_context():
  719. from app.runner.nodes import NodeRegistry
  720. class Executor:
  721. def __init__(self):
  722. self.correlation_id = None
  723. def execute(self, _node, _parameters, **kwargs):
  724. self.correlation_id = kwargs["correlation_id"]
  725. return {"ok": True}
  726. executor = Executor()
  727. correlation_id = new_governance_uid()
  728. assert NodeRegistry({"rule.apply": executor}).execute(
  729. {"type": "rule.apply"},
  730. {},
  731. write_authorized=True,
  732. correlation_id=correlation_id,
  733. ) == {"ok": True}
  734. assert executor.correlation_id == correlation_id