test_data_rule_schema.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. from __future__ import annotations
  2. import importlib.util
  3. from pathlib import Path
  4. import pytest
  5. ROOT = Path(__file__).resolve().parents[1]
  6. MIGRATION = (
  7. ROOT
  8. / "migrations"
  9. / "versions"
  10. / "20260723_110_ai_data_rules.py"
  11. )
  12. RUNTIME_MIGRATION = (
  13. ROOT
  14. / "migrations"
  15. / "versions"
  16. / "20260723_120_rule_execution_runtime.py"
  17. )
  18. PLAN_STATUS_MIGRATION = (
  19. ROOT
  20. / "migrations"
  21. / "versions"
  22. / "20260723_130_bound_plan_lifecycle.py"
  23. )
  24. ARTIFACT_CATALOG_MIGRATION = (
  25. ROOT
  26. / "migrations"
  27. / "versions"
  28. / "20260723_140_rule_run_artifacts.py"
  29. )
  30. ARTIFACT_HANDOFF_MIGRATION = (
  31. ROOT
  32. / "migrations"
  33. / "versions"
  34. / "20260723_150_rule_artifact_handoff_state.py"
  35. )
  36. RULE_EVIDENCE_MIGRATION = (
  37. ROOT
  38. / "migrations"
  39. / "versions"
  40. / "20260723_160_rule_execution_evidence.py"
  41. )
  42. RULE_ATTEMPT_MIGRATION = (
  43. ROOT
  44. / "migrations"
  45. / "versions"
  46. / "20260723_170_rule_execution_attempts.py"
  47. )
  48. RULE_RECONCILIATION_MIGRATION = (
  49. ROOT
  50. / "migrations"
  51. / "versions"
  52. / "20260723_180_rule_reconciliation.py"
  53. )
  54. RULE_CLEANUP_LEASE_MIGRATION = (
  55. ROOT
  56. / "migrations"
  57. / "versions"
  58. / "20260723_190_rule_cleanup_leases.py"
  59. )
  60. EXPECTED_TABLES = {
  61. "data_rules",
  62. "data_rule_versions",
  63. "rule_generation_runs",
  64. "data_standards",
  65. "data_standard_versions",
  66. "standard_rule_bindings",
  67. "dataflow_versions",
  68. "dataflow_component_bindings",
  69. "rule_execution_plans",
  70. "rule_artifacts",
  71. "dataflow_deployments",
  72. "rule_runs",
  73. "rule_violation_samples",
  74. }
  75. def test_ai_data_rule_migration_defines_all_three_domain_layers():
  76. source = MIGRATION.read_text(encoding="utf-8")
  77. assert 'revision = "20260723_110"' in source
  78. assert 'down_revision = "20260722_110"' in source
  79. for table in EXPECTED_TABLES:
  80. assert f"CREATE TABLE public.{table}" in source
  81. def test_ai_data_rule_migration_enforces_immutable_versions_and_fixed_bindings():
  82. source = MIGRATION.read_text(encoding="utf-8")
  83. for expected in (
  84. "UNIQUE (rule_uid, version_no)",
  85. "UNIQUE (standard_uid, version_no)",
  86. "UNIQUE (dataflow_uid, version_no)",
  87. "standard_version_id UUID NOT NULL",
  88. "rule_version_id UUID NOT NULL",
  89. "dataflow_version_id UUID NOT NULL",
  90. "component_kind VARCHAR(30) NOT NULL",
  91. "package_hash CHAR(64)",
  92. "plan_hash CHAR(64)",
  93. "artifact_digest CHAR(64)",
  94. "context_hash CHAR(64)",
  95. "candidate_hash CHAR(64)",
  96. "workflow_version_id UUID",
  97. ):
  98. assert expected in source
  99. def test_ai_data_rule_migration_is_forward_preserving():
  100. source = MIGRATION.read_text(encoding="utf-8")
  101. downgrade = source.split("def downgrade()", 1)[1]
  102. assert "DROP TABLE" not in downgrade.upper()
  103. assert "pass" in downgrade
  104. def test_dataflow_draft_reservation_migration_is_single_use_and_forward_only():
  105. source = (
  106. ROOT
  107. / "migrations/versions/20260723_210_dataflow_draft_reservations.py"
  108. ).read_text(encoding="utf-8")
  109. assert 'revision = "20260723_210"' in source
  110. assert 'down_revision = "20260723_200"' in source
  111. assert "dataflow_draft_reservations" in source
  112. assert "dataflow_uid UUID NOT NULL UNIQUE" in source
  113. assert "actor_uid UUID NOT NULL" in source
  114. assert "nonce_hash CHAR(64) NOT NULL UNIQUE" in source
  115. assert "consumed_at TIMESTAMPTZ" in source
  116. assert "expires_at TIMESTAMPTZ NOT NULL" in source
  117. assert "forward-only" in source
  118. def test_rule_execution_runtime_migration_adds_pinned_runtime_evidence():
  119. source = RUNTIME_MIGRATION.read_text(encoding="utf-8")
  120. assert 'revision = "20260723_120"' in source
  121. assert 'down_revision = "20260723_110"' in source
  122. for table in (
  123. "data_schema_snapshots",
  124. "dataflow_dataset_bindings",
  125. "rule_compile_evidence",
  126. "rule_test_evidence",
  127. ):
  128. assert f"CREATE TABLE public.{table}" in source
  129. for expected in (
  130. "rule_execution_plan_id UUID NOT NULL",
  131. "created_by UUID REFERENCES public.users(id)",
  132. "candidate JSONB",
  133. "UNIQUE (schema_ref, schema_hash)",
  134. "UNIQUE (dataflow_deployment_id, logical_ref)",
  135. ):
  136. assert expected in source
  137. def test_rule_execution_runtime_migration_remains_forward_preserving():
  138. source = RUNTIME_MIGRATION.read_text(encoding="utf-8")
  139. downgrade = source.split("def downgrade()", 1)[1]
  140. assert "DROP TABLE" not in downgrade.upper()
  141. assert "pass" in downgrade
  142. def test_bound_plan_status_migration_is_forward_only_and_keeps_compiled_valid():
  143. source = PLAN_STATUS_MIGRATION.read_text(encoding="utf-8")
  144. assert 'revision = "20260723_130"' in source
  145. assert 'down_revision = "20260723_120"' in source
  146. assert "'compiled','published','revoked'" in source
  147. assert "'tested'" not in source
  148. assert "raise RuntimeError" in source.split("def downgrade()", 1)[1]
  149. spec = importlib.util.spec_from_file_location(
  150. "bound_plan_status_migration",
  151. PLAN_STATUS_MIGRATION,
  152. )
  153. assert spec is not None and spec.loader is not None
  154. module = importlib.util.module_from_spec(spec)
  155. spec.loader.exec_module(module)
  156. with pytest.raises(RuntimeError, match="forward-only|cannot downgrade"):
  157. module.downgrade()
  158. def test_rule_run_artifact_catalog_is_correlation_scoped_and_forward_preserving():
  159. source = ARTIFACT_CATALOG_MIGRATION.read_text(encoding="utf-8")
  160. assert 'revision = "20260723_140"' in source
  161. assert 'down_revision = "20260723_130"' in source
  162. assert "CREATE TABLE public.rule_run_artifacts" in source
  163. for expected in (
  164. "correlation_id UUID NOT NULL",
  165. "binding_id UUID NOT NULL",
  166. "artifact_ref VARCHAR(1000) NOT NULL",
  167. "artifact_digest CHAR(64) NOT NULL",
  168. "schema_fields JSONB NOT NULL",
  169. "expires_at TIMESTAMPTZ NOT NULL",
  170. "UNIQUE (correlation_id, binding_id, artifact_digest)",
  171. ):
  172. assert expected in source
  173. downgrade = source.split("def downgrade()", 1)[1]
  174. assert "DROP TABLE" not in downgrade.upper()
  175. assert "pass" in downgrade
  176. def test_artifact_handoff_state_migration_upgrades_old_140_forward_only():
  177. source = ARTIFACT_HANDOFF_MIGRATION.read_text(encoding="utf-8")
  178. assert 'revision = "20260723_150"' in source
  179. assert 'down_revision = "20260723_140"' in source
  180. for expected in (
  181. "binding_hash CHAR(64)",
  182. "handoff_status VARCHAR(20)",
  183. "ready_at TIMESTAMPTZ",
  184. "failed_at TIMESTAMPTZ",
  185. "updated_at TIMESTAMPTZ",
  186. "UNIQUE (correlation_id, binding_id, artifact_kind)",
  187. "'pending','ready','failed'",
  188. "SELECT binding_hash",
  189. ):
  190. assert expected in source
  191. assert "artifact_digest" in source
  192. assert "RAISE EXCEPTION" in source
  193. downgrade = source.split("def downgrade()", 1)[1]
  194. assert "DROP TABLE" not in downgrade.upper()
  195. assert "raise RuntimeError" in downgrade
  196. spec = importlib.util.spec_from_file_location(
  197. "rule_artifact_handoff_state_migration",
  198. ARTIFACT_HANDOFF_MIGRATION,
  199. )
  200. assert spec is not None and spec.loader is not None
  201. module = importlib.util.module_from_spec(spec)
  202. spec.loader.exec_module(module)
  203. with pytest.raises(RuntimeError, match="forward-only|cannot downgrade"):
  204. module.downgrade()
  205. def test_rule_evidence_upgrade_preflights_legacy_duplicates_and_oversize():
  206. source = RULE_EVIDENCE_MIGRATION.read_text(encoding="utf-8")
  207. duplicate_check = source.index("HAVING COUNT(*) > 1")
  208. unique_constraint = source.index(
  209. "ADD CONSTRAINT rule_violation_samples_rule_run_key"
  210. )
  211. assert duplicate_check < unique_constraint
  212. assert "duplicate rule_run_id" in source
  213. assert "sample_count > 100" in source
  214. assert "rows above 100" in source
  215. def test_rule_attempt_migration_adds_exact_identity_replay_lease_and_receipts():
  216. source = RULE_ATTEMPT_MIGRATION.read_text(encoding="utf-8")
  217. assert 'revision = "20260723_170"' in source
  218. assert 'down_revision = "20260723_160"' in source
  219. for expected in (
  220. "lease_owner UUID",
  221. "lease_expires_at TIMESTAMPTZ",
  222. "evidence_digest CHAR(64)",
  223. "deployment_id UUID",
  224. "environment VARCHAR(20)",
  225. "replay_body JSONB",
  226. "replay_digest CHAR(64)",
  227. "CREATE TABLE public.rule_sql_staging_receipts",
  228. "producer_rule_run_id UUID NOT NULL",
  229. "relation_digest CHAR(64) NOT NULL",
  230. "UNIQUE (producer_rule_run_id, output_binding_id)",
  231. ):
  232. assert expected in source
  233. assert "DROP TABLE" not in source.split("def downgrade()", 1)[1].upper()
  234. def test_rule_reconciliation_migration_extends_170_without_rewriting_it():
  235. source = RULE_RECONCILIATION_MIGRATION.read_text(encoding="utf-8")
  236. assert 'revision = "20260723_180"' in source
  237. assert 'down_revision = "20260723_170"' in source
  238. for expected in (
  239. "runner_task_executions",
  240. "lease_expires_at",
  241. "'unknown','expired'",
  242. "cleanup_claim UUID",
  243. "schema_fields JSONB",
  244. "rule_sql_staging_receipts",
  245. ):
  246. assert expected in source
  247. assert "forward-only" in source
  248. def test_rule_cleanup_lease_migration_extends_180_forward_only():
  249. source = RULE_CLEANUP_LEASE_MIGRATION.read_text(encoding="utf-8")
  250. assert 'revision = "20260723_190"' in source
  251. assert 'down_revision = "20260723_180"' in source
  252. assert source.count("cleanup_claim_expires_at TIMESTAMPTZ") == 2
  253. assert "rule_violation_samples" in source
  254. assert "rule_sql_staging_receipts" in source
  255. assert source.count(
  256. "SET cleanup_claim_expires_at = CURRENT_TIMESTAMP"
  257. ) == 2
  258. assert source.count("WHERE cleanup_claim IS NOT NULL") == 2
  259. assert source.count("AND cleanup_claim_expires_at IS NULL") == 2
  260. assert "forward-only" in source