kiri-cli 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kiri-cli might be problematic. Click here for more details.

Files changed (743) hide show
  1. kiri_adapters/__init__.py +193 -0
  2. kiri_adapters/backends/__init__.py +0 -0
  3. kiri_adapters/backends/databricks.manifest.yml +55 -0
  4. kiri_adapters/backends/databricks.py +2980 -0
  5. kiri_adapters/backends/fabric_dw.manifest.yml +17 -0
  6. kiri_adapters/backends/fabric_dw.py +307 -0
  7. kiri_adapters/backends/fabric_lakehouse.manifest.yml +19 -0
  8. kiri_adapters/backends/fabric_lakehouse.py +365 -0
  9. kiri_adapters/backends/iceberg.manifest.yml +17 -0
  10. kiri_adapters/backends/iceberg.py +451 -0
  11. kiri_adapters/backends/localduckdb.manifest.yml +43 -0
  12. kiri_adapters/backends/localduckdb.py +1295 -0
  13. kiri_adapters/backends/mssql.manifest.yml +17 -0
  14. kiri_adapters/backends/mssql.py +366 -0
  15. kiri_adapters/backends/postgres.manifest.yml +17 -0
  16. kiri_adapters/backends/postgres.py +434 -0
  17. kiri_adapters/backends/trino.manifest.yml +17 -0
  18. kiri_adapters/backends/trino.py +542 -0
  19. kiri_adapters/base.py +892 -0
  20. kiri_adapters/doctor_checks.py +81 -0
  21. kiri_adapters/dv_planner.py +1205 -0
  22. kiri_adapters/dv_planner_bridge.py +265 -0
  23. kiri_adapters/dv_planner_pit.py +265 -0
  24. kiri_adapters/dv_planner_ref.py +403 -0
  25. kiri_adapters/dv_planner_sat_variants.py +668 -0
  26. kiri_adapters/framework.py +112 -0
  27. kiri_adapters/incident/__init__.py +30 -0
  28. kiri_adapters/incident/_http.py +121 -0
  29. kiri_adapters/incident/base.py +89 -0
  30. kiri_adapters/incident/jira.py +365 -0
  31. kiri_adapters/incident/servicenow.py +314 -0
  32. kiri_adapters/incident/zendesk.py +285 -0
  33. kiri_adapters/manifest.py +466 -0
  34. kiri_adapters/orchestration/__init__.py +30 -0
  35. kiri_adapters/orchestration/airflow.py +117 -0
  36. kiri_adapters/orchestration/artifact_emitter.py +99 -0
  37. kiri_adapters/orchestration/autoloader_emitter.py +141 -0
  38. kiri_adapters/orchestration/azure_data_factory.py +137 -0
  39. kiri_adapters/orchestration/base.py +626 -0
  40. kiri_adapters/orchestration/databricks_workflows.py +1768 -0
  41. kiri_adapters/orchestration/k8s_cron.py +555 -0
  42. kiri_adapters/orchestration/lifecycle.py +222 -0
  43. kiri_adapters/orchestration/pipeline_compat.py +145 -0
  44. kiri_adapters/orchestration/runtime_python.py +188 -0
  45. kiri_adapters/paths.py +38 -0
  46. kiri_adapters/protocols/__init__.py +64 -0
  47. kiri_adapters/protocols/ai_service/__init__.py +7 -0
  48. kiri_adapters/protocols/ai_service/conformance.py +120 -0
  49. kiri_adapters/protocols/base.py +220 -0
  50. kiri_adapters/protocols/catalog/__init__.py +7 -0
  51. kiri_adapters/protocols/catalog/conformance.py +138 -0
  52. kiri_adapters/protocols/framework/__init__.py +7 -0
  53. kiri_adapters/protocols/framework/conformance.py +98 -0
  54. kiri_adapters/protocols/incident/__init__.py +7 -0
  55. kiri_adapters/protocols/incident/conformance.py +64 -0
  56. kiri_adapters/protocols/orchestration/__init__.py +7 -0
  57. kiri_adapters/protocols/orchestration/conformance.py +84 -0
  58. kiri_adapters/protocols/platform/__init__.py +7 -0
  59. kiri_adapters/protocols/platform/conformance.py +120 -0
  60. kiri_adapters/protocols/sql_dialect/__init__.py +7 -0
  61. kiri_adapters/protocols/sql_dialect/conformance.py +91 -0
  62. kiri_adapters/protocols/vault/__init__.py +7 -0
  63. kiri_adapters/protocols/vault/conformance.py +65 -0
  64. kiri_adapters/py.typed +0 -0
  65. kiri_adapters/registry.py +104 -0
  66. kiri_adapters/semantic/__init__.py +60 -0
  67. kiri_adapters/semantic/apply.py +274 -0
  68. kiri_adapters/semantic/base.py +530 -0
  69. kiri_adapters/semantic/cube.py +280 -0
  70. kiri_adapters/semantic/dbt_semantic.py +92 -0
  71. kiri_adapters/semantic/metric_views.py +249 -0
  72. kiri_adapters/semantic/metricflow.py +345 -0
  73. kiri_adapters/semantic/snowflake_views.py +75 -0
  74. kiri_adapters/sql_dialect.py +1592 -0
  75. kiri_adapters/tsql_to_spark.py +653 -0
  76. kiri_ai_gateway/__init__.py +147 -0
  77. kiri_ai_gateway/audit.py +334 -0
  78. kiri_ai_gateway/batch.py +293 -0
  79. kiri_ai_gateway/config.py +165 -0
  80. kiri_ai_gateway/gateway.py +642 -0
  81. kiri_ai_gateway/placeholder_guard.py +145 -0
  82. kiri_ai_gateway/prices/__init__.py +77 -0
  83. kiri_ai_gateway/prices/prices.yaml +64 -0
  84. kiri_ai_gateway/prompts/__init__.py +54 -0
  85. kiri_ai_gateway/prompts/airbyte_source_setup.yaml +212 -0
  86. kiri_ai_gateway/prompts/bdv_translation.yaml +90 -0
  87. kiri_ai_gateway/prompts/contract_draft_mvp.yaml +117 -0
  88. kiri_ai_gateway/prompts/describe_table.yaml +19 -0
  89. kiri_ai_gateway/prompts/design_gold_from_question.yaml +130 -0
  90. kiri_ai_gateway/prompts/detect_pii.yaml +59 -0
  91. kiri_ai_gateway/prompts/discover_classify_column.yaml +69 -0
  92. kiri_ai_gateway/prompts/discover_describe_table.yaml +61 -0
  93. kiri_ai_gateway/prompts/dv_quality_summary.yaml +36 -0
  94. kiri_ai_gateway/prompts/incident_assist.yaml +58 -0
  95. kiri_ai_gateway/prompts/infer_source_schema.yaml +31 -0
  96. kiri_ai_gateway/prompts/ingest_backend_chooser.yaml +116 -0
  97. kiri_ai_gateway/prompts/interactive_source_edit.yaml +94 -0
  98. kiri_ai_gateway/prompts/map_needed_entity_to_source.yaml +72 -0
  99. kiri_ai_gateway/prompts/meet_kiri.yaml +127 -0
  100. kiri_ai_gateway/prompts/native_source_setup.yaml +211 -0
  101. kiri_ai_gateway/prompts/source_grouping_reason.yaml +29 -0
  102. kiri_ai_gateway/prompts/suggest_attribute_classification.yaml +42 -0
  103. kiri_ai_gateway/prompts/suggest_data_tests.yaml +71 -0
  104. kiri_ai_gateway/prompts/suggest_gold_dimension.yaml +108 -0
  105. kiri_ai_gateway/prompts/suggest_gold_fact.yaml +107 -0
  106. kiri_ai_gateway/prompts/suggest_gold_model.yaml +70 -0
  107. kiri_ai_gateway/prompts/suggest_semantic_intent.yaml +91 -0
  108. kiri_ai_gateway/prompts/suggest_silver_model.yaml +140 -0
  109. kiri_ai_gateway/providers/__init__.py +7 -0
  110. kiri_ai_gateway/providers/anthropic_provider.py +26 -0
  111. kiri_ai_gateway/providers/gateway_provider.py +188 -0
  112. kiri_ai_gateway/providers/local_provider.py +177 -0
  113. kiri_ai_gateway/py.typed +0 -0
  114. kiri_ai_gateway/service.py +283 -0
  115. kiri_ai_gateway/transport.py +103 -0
  116. kiri_ai_gateway/transports/__init__.py +12 -0
  117. kiri_ai_gateway/transports/anthropic_transport.py +73 -0
  118. kiri_ai_gateway/transports/ollama_transport.py +82 -0
  119. kiri_ai_gateway/transports/openai_compatible_transport.py +125 -0
  120. kiri_ai_gateway/workspace_policy.py +52 -0
  121. kiri_catalog/__init__.py +199 -0
  122. kiri_catalog/annotation_plan.py +183 -0
  123. kiri_catalog/appearances.py +155 -0
  124. kiri_catalog/backends/__init__.py +79 -0
  125. kiri_catalog/backends/base.py +167 -0
  126. kiri_catalog/backends/memory.py +241 -0
  127. kiri_catalog/backends/postgres.py +497 -0
  128. kiri_catalog/backends/redacting.py +195 -0
  129. kiri_catalog/backends/sqlite.py +480 -0
  130. kiri_catalog/context.py +94 -0
  131. kiri_catalog/federation/__init__.py +114 -0
  132. kiri_catalog/federation/api.py +200 -0
  133. kiri_catalog/federation/cache.py +149 -0
  134. kiri_catalog/federation/emit.py +209 -0
  135. kiri_catalog/federation/fs_resolver.py +295 -0
  136. kiri_catalog/federation/http_resolver.py +486 -0
  137. kiri_catalog/federation/metrics.py +123 -0
  138. kiri_catalog/federation/release.py +342 -0
  139. kiri_catalog/federation/resolver.py +332 -0
  140. kiri_catalog/federation/schema.py +460 -0
  141. kiri_catalog/importer.py +668 -0
  142. kiri_catalog/lineage_drift.py +280 -0
  143. kiri_catalog/lineage_graph.py +176 -0
  144. kiri_catalog/lineage_graph_explain.py +96 -0
  145. kiri_catalog/lineage_graph_mermaid.py +230 -0
  146. kiri_catalog/observed_uc_lineage.py +216 -0
  147. kiri_catalog/pii_detect.py +176 -0
  148. kiri_catalog/push.py +122 -0
  149. kiri_catalog/py.typed +0 -0
  150. kiri_catalog/redact.py +101 -0
  151. kiri_catalog/review_actions.py +119 -0
  152. kiri_catalog/schema/__init__.py +70 -0
  153. kiri_catalog/schema/asset.py +142 -0
  154. kiri_catalog/schema/column.py +247 -0
  155. kiri_catalog/schema/enums.py +195 -0
  156. kiri_catalog/schema/job.py +51 -0
  157. kiri_catalog/schema/lineage.py +33 -0
  158. kiri_catalog/schema/policy.py +69 -0
  159. kiri_catalog/schema/primitives.py +90 -0
  160. kiri_catalog/schema/review_state.py +111 -0
  161. kiri_catalog/schema/urn.py +45 -0
  162. kiri_catalog/schema_discovery.py +160 -0
  163. kiri_catalog/sinks/__init__.py +64 -0
  164. kiri_catalog/sinks/base.py +178 -0
  165. kiri_catalog/sinks/horizon.py +195 -0
  166. kiri_catalog/sinks/purview.py +216 -0
  167. kiri_catalog/sinks/unity.py +352 -0
  168. kiri_catalog/sinks/unity_executor.py +543 -0
  169. kiri_catalog/sources/__init__.py +42 -0
  170. kiri_catalog/sources/base.py +104 -0
  171. kiri_catalog/sources/unity.py +465 -0
  172. kiri_catalog/steward_writeback.py +158 -0
  173. kiri_catalog/tag_sql.py +99 -0
  174. kiri_cli/LICENSE +105 -0
  175. kiri_cli/__init__.py +1 -0
  176. kiri_cli/_apply_cli.py +63 -0
  177. kiri_cli/_apply_reporter.py +38 -0
  178. kiri_cli/_closed_menu.py +36 -0
  179. kiri_cli/_fixture_option.py +18 -0
  180. kiri_cli/_layer_detect.py +158 -0
  181. kiri_cli/_project_option.py +79 -0
  182. kiri_cli/adapter_cmd.py +192 -0
  183. kiri_cli/ai_batch_cmd.py +258 -0
  184. kiri_cli/ai_cmd.py +352 -0
  185. kiri_cli/approve_cmd.py +659 -0
  186. kiri_cli/attr_cmd.py +575 -0
  187. kiri_cli/audit_cmd.py +348 -0
  188. kiri_cli/audit_correlate_cmd.py +648 -0
  189. kiri_cli/audit_join.py +437 -0
  190. kiri_cli/audit_join_cmd.py +160 -0
  191. kiri_cli/auto_cmd.py +97 -0
  192. kiri_cli/azure_cmd.py +243 -0
  193. kiri_cli/catalog.py +1293 -0
  194. kiri_cli/cli_reference_live.py +147 -0
  195. kiri_cli/compile_cmd.py +493 -0
  196. kiri_cli/compliance_cmd.py +221 -0
  197. kiri_cli/contract_approval_cmd.py +94 -0
  198. kiri_cli/contract_cmd.py +64 -0
  199. kiri_cli/contract_codeowners_cmd.py +162 -0
  200. kiri_cli/contract_diff.py +111 -0
  201. kiri_cli/contract_emit.py +90 -0
  202. kiri_cli/contract_export.py +192 -0
  203. kiri_cli/contract_lint.py +1505 -0
  204. kiri_cli/contract_merge_cmd.py +264 -0
  205. kiri_cli/contract_new.py +326 -0
  206. kiri_cli/contract_promote.py +216 -0
  207. kiri_cli/contract_scaffold_cmd.py +134 -0
  208. kiri_cli/contract_schema_export.py +69 -0
  209. kiri_cli/contract_show.py +150 -0
  210. kiri_cli/contract_stubs.py +60 -0
  211. kiri_cli/contract_sync_from_live.py +327 -0
  212. kiri_cli/contract_validate.py +154 -0
  213. kiri_cli/contract_verify_live.py +242 -0
  214. kiri_cli/cost_cmd.py +243 -0
  215. kiri_cli/databricks_health.py +1254 -0
  216. kiri_cli/databricks_setup_cmd.py +712 -0
  217. kiri_cli/discover_cmd.py +600 -0
  218. kiri_cli/docs_cmd.py +35 -0
  219. kiri_cli/doctor_cmd.py +145 -0
  220. kiri_cli/dv_cmd.py +814 -0
  221. kiri_cli/dv_conformance_cmd.py +154 -0
  222. kiri_cli/dv_install.py +493 -0
  223. kiri_cli/dv_migrate_cmd.py +183 -0
  224. kiri_cli/dv_test_cmd.py +175 -0
  225. kiri_cli/federation_cmd.py +315 -0
  226. kiri_cli/fk_cmd.py +160 -0
  227. kiri_cli/flows_cmd.py +910 -0
  228. kiri_cli/governance_cmd.py +358 -0
  229. kiri_cli/incident_api_cmd.py +180 -0
  230. kiri_cli/incident_cmd.py +410 -0
  231. kiri_cli/incident_setup_cmd.py +239 -0
  232. kiri_cli/ingest.py +427 -0
  233. kiri_cli/ingest_discover.py +480 -0
  234. kiri_cli/ingest_new_source.py +386 -0
  235. kiri_cli/intent_cmd.py +122 -0
  236. kiri_cli/invariants_cmd.py +118 -0
  237. kiri_cli/kiri_banner.py +161 -0
  238. kiri_cli/kpi_cmd.py +222 -0
  239. kiri_cli/layer_cmd.py +464 -0
  240. kiri_cli/lineage.py +601 -0
  241. kiri_cli/main.py +2472 -0
  242. kiri_cli/maturity_cmd.py +222 -0
  243. kiri_cli/migrate_cmd.py +1347 -0
  244. kiri_cli/onboard_cmd.py +223 -0
  245. kiri_cli/pack_cmd.py +496 -0
  246. kiri_cli/pipeline_cmd.py +127 -0
  247. kiri_cli/pre_classify_cmd.py +274 -0
  248. kiri_cli/project_cmd.py +592 -0
  249. kiri_cli/py.typed +0 -0
  250. kiri_cli/quality_cmd.py +295 -0
  251. kiri_cli/rbac.py +139 -0
  252. kiri_cli/rbac_cmd.py +291 -0
  253. kiri_cli/release.py +850 -0
  254. kiri_cli/release_semver.py +265 -0
  255. kiri_cli/repo_cmd.py +155 -0
  256. kiri_cli/retention_cmd.py +333 -0
  257. kiri_cli/sample_data/__init__.py +12 -0
  258. kiri_cli/sample_data/customers.contract.yml +47 -0
  259. kiri_cli/sample_data/customers.csv +25 -0
  260. kiri_cli/scaffold_cmd.py +721 -0
  261. kiri_cli/schema_cmd.py +185 -0
  262. kiri_cli/semantic_cmd.py +605 -0
  263. kiri_cli/silver_cmd.py +348 -0
  264. kiri_cli/skills_cmd.py +243 -0
  265. kiri_cli/skills_data/README.md +51 -0
  266. kiri_cli/skills_data/add-source/SKILL.md +116 -0
  267. kiri_cli/skills_data/azure-deploy/SKILL.md +261 -0
  268. kiri_cli/skills_data/azure-status/SKILL.md +111 -0
  269. kiri_cli/skills_data/compliance-check/SKILL.md +99 -0
  270. kiri_cli/skills_data/databricks-build-pipeline/SKILL.md +112 -0
  271. kiri_cli/skills_data/databricks-project-setup/SKILL.md +157 -0
  272. kiri_cli/skills_data/debug-apply/SKILL.md +186 -0
  273. kiri_cli/skills_data/draft-gold-model/SKILL.md +203 -0
  274. kiri_cli/skills_data/explain-kpi/SKILL.md +158 -0
  275. kiri_cli/skills_data/incident-coach/SKILL.md +135 -0
  276. kiri_cli/skills_data/incident-setup/SKILL.md +112 -0
  277. kiri_cli/skills_data/maturity-coach/SKILL.md +120 -0
  278. kiri_cli/skills_data/promote-contract/SKILL.md +91 -0
  279. kiri_cli/skills_data/review-attributes/SKILL.md +94 -0
  280. kiri_cli/sla_cmd.py +769 -0
  281. kiri_cli/source_cmd.py +909 -0
  282. kiri_cli/templates/__init__.py +6 -0
  283. kiri_cli/templates/dv4dbt_packages.yml.j2 +11 -0
  284. kiri_cli/upgrade_cmd.py +896 -0
  285. kiri_cli/use_cmd.py +120 -0
  286. kiri_cli/vault_cmd.py +241 -0
  287. kiri_cli/verify_generated.py +241 -0
  288. kiri_cli-1.0.0b3.dist-info/METADATA +212 -0
  289. kiri_cli-1.0.0b3.dist-info/RECORD +743 -0
  290. kiri_cli-1.0.0b3.dist-info/WHEEL +4 -0
  291. kiri_cli-1.0.0b3.dist-info/entry_points.txt +4 -0
  292. kiri_compliance/__init__.py +33 -0
  293. kiri_compliance/audit_coverage.py +354 -0
  294. kiri_compliance/controls.py +763 -0
  295. kiri_compliance/evidence.py +155 -0
  296. kiri_compliance/py.typed +0 -0
  297. kiri_compliance/quality_engine.py +246 -0
  298. kiri_compliance/renderer.py +195 -0
  299. kiri_compliance/report.py +44 -0
  300. kiri_compliance/schema.py +129 -0
  301. kiri_core/__init__.py +185 -0
  302. kiri_core/adapter_surfaces.py +149 -0
  303. kiri_core/ai_query_gate/__init__.py +92 -0
  304. kiri_core/ai_query_gate/audit.py +153 -0
  305. kiri_core/ai_query_gate/evaluator.py +484 -0
  306. kiri_core/ai_query_gate/models.py +162 -0
  307. kiri_core/ai_query_gate/parser.py +436 -0
  308. kiri_core/ai_query_gate/resolver.py +274 -0
  309. kiri_core/ai_query_gate/store.py +234 -0
  310. kiri_core/ai_query_gate/store_postgres.py +409 -0
  311. kiri_core/api_keys.py +243 -0
  312. kiri_core/apply/__init__.py +27 -0
  313. kiri_core/apply/conformance.py +99 -0
  314. kiri_core/apply/incident_router.py +176 -0
  315. kiri_core/apply/models.py +140 -0
  316. kiri_core/apply/reporter.py +46 -0
  317. kiri_core/apply/runner.py +3083 -0
  318. kiri_core/apply_log_store.py +155 -0
  319. kiri_core/approval_flow/__init__.py +133 -0
  320. kiri_core/approval_flow/integration.py +156 -0
  321. kiri_core/approval_flow/models.py +443 -0
  322. kiri_core/approval_flow/notifier.py +263 -0
  323. kiri_core/approval_flow/orchestrator.py +660 -0
  324. kiri_core/approval_flow/reconcile_signoff.py +53 -0
  325. kiri_core/approval_flow/store.py +220 -0
  326. kiri_core/approval_flow/store_jsonl.py +217 -0
  327. kiri_core/approval_flow/store_postgres.py +580 -0
  328. kiri_core/approval_policy.py +116 -0
  329. kiri_core/asgi_trace.py +160 -0
  330. kiri_core/compliance.py +802 -0
  331. kiri_core/cutover_approval.py +187 -0
  332. kiri_core/discovery/__init__.py +109 -0
  333. kiri_core/discovery/ai/__init__.py +37 -0
  334. kiri_core/discovery/ai/_payload.py +115 -0
  335. kiri_core/discovery/ai/budget.py +83 -0
  336. kiri_core/discovery/ai/classify.py +223 -0
  337. kiri_core/discovery/ai/describe.py +200 -0
  338. kiri_core/discovery/ai/errors.py +49 -0
  339. kiri_core/discovery/discover.py +648 -0
  340. kiri_core/discovery/heuristics/__init__.py +42 -0
  341. kiri_core/discovery/heuristics/classification.py +174 -0
  342. kiri_core/discovery/heuristics/medallion.py +157 -0
  343. kiri_core/discovery/heuristics/pii.py +92 -0
  344. kiri_core/discovery/introspector.py +636 -0
  345. kiri_core/discovery/introspector_databricks.py +396 -0
  346. kiri_core/discovery/introspector_postgres.py +394 -0
  347. kiri_core/discovery/introspector_snowflake.py +383 -0
  348. kiri_core/discovery/reporting/__init__.py +17 -0
  349. kiri_core/discovery/reporting/discovery_report.py +190 -0
  350. kiri_core/discovery/scaffold/__init__.py +41 -0
  351. kiri_core/discovery/scaffold/contract_writer.py +632 -0
  352. kiri_core/discovery/scaffold/provenance_overlay.py +143 -0
  353. kiri_core/discovery/scaffold/source_writer.py +211 -0
  354. kiri_core/discovery/scaffold/todo_marker.py +98 -0
  355. kiri_core/domain_policy.py +122 -0
  356. kiri_core/goal_coverage.py +82 -0
  357. kiri_core/gold_registry.py +211 -0
  358. kiri_core/health.py +192 -0
  359. kiri_core/incident.py +204 -0
  360. kiri_core/incident_builders.py +256 -0
  361. kiri_core/incident_log.py +418 -0
  362. kiri_core/intent/__init__.py +46 -0
  363. kiri_core/intent/agent_runner.py +82 -0
  364. kiri_core/intent/project_dv.py +261 -0
  365. kiri_core/intent/propose.py +132 -0
  366. kiri_core/intent/semantic_intent.py +446 -0
  367. kiri_core/invariants.py +301 -0
  368. kiri_core/kpi.py +373 -0
  369. kiri_core/latency.py +150 -0
  370. kiri_core/lifecycle/__init__.py +555 -0
  371. kiri_core/lifecycle/ai_index.py +453 -0
  372. kiri_core/lifecycle/auto_mode.py +172 -0
  373. kiri_core/lifecycle/bootstrap.py +316 -0
  374. kiri_core/lifecycle/cli_reference.py +1064 -0
  375. kiri_core/lifecycle/commit.py +258 -0
  376. kiri_core/lifecycle/compile_dispatch.py +296 -0
  377. kiri_core/lifecycle/compile_range.py +665 -0
  378. kiri_core/lifecycle/contract_inventory.py +354 -0
  379. kiri_core/lifecycle/contracts.py +250 -0
  380. kiri_core/lifecycle/doctor.py +412 -0
  381. kiri_core/lifecycle/git_remote.py +948 -0
  382. kiri_core/lifecycle/identity.py +147 -0
  383. kiri_core/lifecycle/ingestion_modes.py +215 -0
  384. kiri_core/lifecycle/layer_contract.py +452 -0
  385. kiri_core/lifecycle/layer_contract_migrate.py +969 -0
  386. kiri_core/lifecycle/layer_contract_parser.py +436 -0
  387. kiri_core/lifecycle/layer_contract_validator.py +2291 -0
  388. kiri_core/lifecycle/layout.py +298 -0
  389. kiri_core/lifecycle/onboarding.py +306 -0
  390. kiri_core/lifecycle/openlineage_emit.py +153 -0
  391. kiri_core/lifecycle/patch.py +231 -0
  392. kiri_core/lifecycle/plan.py +241 -0
  393. kiri_core/lifecycle/probe.py +204 -0
  394. kiri_core/lifecycle/project_context.py +334 -0
  395. kiri_core/lifecycle/project_status.py +532 -0
  396. kiri_core/lifecycle/scaffold.py +455 -0
  397. kiri_core/lifecycle/source_grouping.py +725 -0
  398. kiri_core/lifecycle/source_introspection.py +488 -0
  399. kiri_core/lifecycle/source_inventory.py +561 -0
  400. kiri_core/lifecycle/source_patch.py +353 -0
  401. kiri_core/lifecycle/sources.py +470 -0
  402. kiri_core/lifecycle/sync_from_live.py +727 -0
  403. kiri_core/lifecycle/validate.py +116 -0
  404. kiri_core/lifecycle/verify_annotations.py +391 -0
  405. kiri_core/lifecycle/verify_live.py +389 -0
  406. kiri_core/lifecycle/verify_metric_views.py +321 -0
  407. kiri_core/lifecycle/watermark.py +371 -0
  408. kiri_core/lifecycle/watermark_apply.py +656 -0
  409. kiri_core/lineage.py +418 -0
  410. kiri_core/loader.py +377 -0
  411. kiri_core/logging.py +374 -0
  412. kiri_core/maturity.py +592 -0
  413. kiri_core/migration_layer_policy.py +285 -0
  414. kiri_core/names.py +163 -0
  415. kiri_core/observed_lineage.py +62 -0
  416. kiri_core/odcs/__init__.py +15 -0
  417. kiri_core/otel_bootstrap.py +326 -0
  418. kiri_core/pipeline_ir.py +332 -0
  419. kiri_core/pipeline_loader.py +82 -0
  420. kiri_core/planning.py +110 -0
  421. kiri_core/project.py +1576 -0
  422. kiri_core/project_docs.py +199 -0
  423. kiri_core/py.typed +0 -0
  424. kiri_core/rate_limit.py +456 -0
  425. kiri_core/release_state/__init__.py +37 -0
  426. kiri_core/release_state/models.py +108 -0
  427. kiri_core/release_state/store.py +634 -0
  428. kiri_core/resilience/__init__.py +288 -0
  429. kiri_core/resilience/retry.py +173 -0
  430. kiri_core/runtime.py +40 -0
  431. kiri_core/schema/__init__.py +62 -0
  432. kiri_core/schema/column.py +140 -0
  433. kiri_core/schema/ingestion.py +665 -0
  434. kiri_core/schema/model.py +44 -0
  435. kiri_core/schema/reporting_goal.py +160 -0
  436. kiri_core/schema/source.py +188 -0
  437. kiri_core/silver/__init__.py +148 -0
  438. kiri_core/silver/apply_runner.py +830 -0
  439. kiri_core/silver/backfill.py +280 -0
  440. kiri_core/silver/dv_backend.py +426 -0
  441. kiri_core/silver/dv_graph.py +932 -0
  442. kiri_core/silver/dv_graph_mermaid.py +443 -0
  443. kiri_core/silver/dv_graph_payload.py +79 -0
  444. kiri_core/silver/dv_graph_views.py +416 -0
  445. kiri_core/silver/graph_projector.py +566 -0
  446. kiri_core/silver/hash_canonicaliser.py +172 -0
  447. kiri_core/silver/hash_policy.py +195 -0
  448. kiri_core/silver/load_ts_policy.py +202 -0
  449. kiri_core/silver/schema_evolution.py +410 -0
  450. kiri_core/silver/zone_rules.py +81 -0
  451. kiri_core/sizing.py +134 -0
  452. kiri_core/storage_paths.py +130 -0
  453. kiri_core/technique_validator.py +700 -0
  454. kiri_core/trace.py +409 -0
  455. kiri_core/types.py +41 -0
  456. kiri_core/urn.py +161 -0
  457. kiri_dbt_bridge/__init__.py +22 -0
  458. kiri_dbt_bridge/_schema_resolve.py +38 -0
  459. kiri_dbt_bridge/bridge.py +94 -0
  460. kiri_dbt_bridge/dbt.manifest.yml +26 -0
  461. kiri_dbt_bridge/dv4dbt/__init__.py +143 -0
  462. kiri_dbt_bridge/dv4dbt/backend.py +204 -0
  463. kiri_dbt_bridge/dv4dbt/bridge_generator.py +289 -0
  464. kiri_dbt_bridge/dv4dbt/compat.py +232 -0
  465. kiri_dbt_bridge/dv4dbt/hub_generator.py +282 -0
  466. kiri_dbt_bridge/dv4dbt/link_generator.py +306 -0
  467. kiri_dbt_bridge/dv4dbt/manifest.py +203 -0
  468. kiri_dbt_bridge/dv4dbt/pit_generator.py +318 -0
  469. kiri_dbt_bridge/dv4dbt/policy_translation.py +249 -0
  470. kiri_dbt_bridge/dv4dbt/project_writer.py +588 -0
  471. kiri_dbt_bridge/dv4dbt/ref_generator.py +404 -0
  472. kiri_dbt_bridge/dv4dbt/render.py +180 -0
  473. kiri_dbt_bridge/dv4dbt/sat_generator.py +480 -0
  474. kiri_dbt_bridge/dv4dbt/sat_variant_generator.py +529 -0
  475. kiri_dbt_bridge/dv4dbt/templates/__init__.py +16 -0
  476. kiri_dbt_bridge/dv4dbt/templates/bridge.sql.j2 +9 -0
  477. kiri_dbt_bridge/dv4dbt/templates/dbt_project.yml.j2 +37 -0
  478. kiri_dbt_bridge/dv4dbt/templates/hub.sql.j2 +9 -0
  479. kiri_dbt_bridge/dv4dbt/templates/link.sql.j2 +9 -0
  480. kiri_dbt_bridge/dv4dbt/templates/packages.yml.j2 +10 -0
  481. kiri_dbt_bridge/dv4dbt/templates/pit.sql.j2 +9 -0
  482. kiri_dbt_bridge/dv4dbt/templates/ref_historised.sql.j2 +9 -0
  483. kiri_dbt_bridge/dv4dbt/templates/ref_non_historised.sql.j2 +9 -0
  484. kiri_dbt_bridge/dv4dbt/templates/sat.sql.j2 +9 -0
  485. kiri_dbt_bridge/dv4dbt/templates/sat_effectivity.sql.j2 +9 -0
  486. kiri_dbt_bridge/dv4dbt/templates/sat_multi_active.sql.j2 +9 -0
  487. kiri_dbt_bridge/dv4dbt/templates/sat_non_historised.sql.j2 +9 -0
  488. kiri_dbt_bridge/dv4dbt/templates/sat_record_tracking.sql.j2 +9 -0
  489. kiri_dbt_bridge/dv4dbt/templates/sat_virtual_end_dated.sql.j2 +9 -0
  490. kiri_dbt_bridge/dv4dbt/templates/sources.yml.j2 +39 -0
  491. kiri_dbt_bridge/framework_adapter.py +310 -0
  492. kiri_dbt_bridge/generate.py +372 -0
  493. kiri_dbt_bridge/gold/__init__.py +21 -0
  494. kiri_dbt_bridge/gold/_sql_emit.py +128 -0
  495. kiri_dbt_bridge/gold/assemble.py +90 -0
  496. kiri_dbt_bridge/gold/canonical_columns.py +168 -0
  497. kiri_dbt_bridge/gold/computed_expressions.py +179 -0
  498. kiri_dbt_bridge/gold/dialects/__init__.py +69 -0
  499. kiri_dbt_bridge/gold/dialects/databricks.py +150 -0
  500. kiri_dbt_bridge/gold/dialects/duckdb.py +120 -0
  501. kiri_dbt_bridge/gold/dialects/trino.py +136 -0
  502. kiri_dbt_bridge/gold/dim_generator.py +688 -0
  503. kiri_dbt_bridge/gold/dimension_refs.py +255 -0
  504. kiri_dbt_bridge/gold/dispatch.py +69 -0
  505. kiri_dbt_bridge/gold/fact_generator.py +534 -0
  506. kiri_dbt_bridge/gold/flat_generator.py +144 -0
  507. kiri_dbt_bridge/gold/lineage.py +484 -0
  508. kiri_dbt_bridge/gold/localized_twins.py +218 -0
  509. kiri_dbt_bridge/gold/measures.py +49 -0
  510. kiri_dbt_bridge/gold/role_playing.py +75 -0
  511. kiri_dbt_bridge/gold/surrogate_key.py +114 -0
  512. kiri_dbt_bridge/macros/kiri_scd1_dim.sql +57 -0
  513. kiri_dbt_bridge/macros/kiri_scd2_dim.sql +188 -0
  514. kiri_dbt_bridge/macros/kiri_sha256.sql +32 -0
  515. kiri_dbt_bridge/macros/kiri_string_cast.sql +37 -0
  516. kiri_dbt_bridge/macros/kiri_surrogate_key.sql +43 -0
  517. kiri_dbt_bridge/profiles.py +162 -0
  518. kiri_dbt_bridge/py.typed +0 -0
  519. kiri_dbt_bridge/safe_ident.py +44 -0
  520. kiri_dbt_bridge/silver_conformance.py +188 -0
  521. kiri_dbt_bridge/silver_kimball/__init__.py +27 -0
  522. kiri_dbt_bridge/silver_kimball/assemble.py +174 -0
  523. kiri_dbt_bridge/silver_kimball/dim_generator.py +602 -0
  524. kiri_dbt_bridge/silver_kimball/dispatch.py +86 -0
  525. kiri_dbt_bridge/silver_kimball/fact_generator.py +320 -0
  526. kiri_dbt_bridge/silver_kimball/flat_generator.py +156 -0
  527. kiri_dbt_bridge/silver_kimball/lineage.py +464 -0
  528. kiri_dbt_bridge/silver_kimball/watermark_window.py +199 -0
  529. kiri_dbt_bridge/silver_kimball/zone.py +68 -0
  530. kiri_dbt_bridge/silver_scd2_curated/__init__.py +46 -0
  531. kiri_dbt_bridge/silver_scd2_curated/assemble.py +213 -0
  532. kiri_dbt_bridge/silver_scd2_curated/curated_generator.py +351 -0
  533. kiri_dbt_bridge/silver_scd2_curated/standardized_generator.py +261 -0
  534. kiri_dbt_bridge/silver_scd2_curated/standardized_plan.py +254 -0
  535. kiri_iceberg/__init__.py +11 -0
  536. kiri_iceberg/catalog.py +128 -0
  537. kiri_iceberg/profile.py +131 -0
  538. kiri_iceberg/py.typed +0 -0
  539. kiri_ingestion/__init__.py +63 -0
  540. kiri_ingestion/airbyte_client.py +428 -0
  541. kiri_ingestion/database_fetcher.py +224 -0
  542. kiri_ingestion/layout.py +71 -0
  543. kiri_ingestion/mssql_introspect.py +565 -0
  544. kiri_ingestion/py.typed +0 -0
  545. kiri_ingestion/rest_fetcher.py +472 -0
  546. kiri_ingestion/runner.py +341 -0
  547. kiri_ingestion/schema_drift.py +222 -0
  548. kiri_ingestion/storage.py +621 -0
  549. kiri_mcp/__init__.py +5 -0
  550. kiri_mcp/audit.py +99 -0
  551. kiri_mcp/audit_decorators.py +252 -0
  552. kiri_mcp/main.py +22 -0
  553. kiri_mcp/py.typed +0 -0
  554. kiri_mcp/rbac.py +110 -0
  555. kiri_mcp/server.py +766 -0
  556. kiri_mcp/skills.py +297 -0
  557. kiri_mcp/tools/__init__.py +70 -0
  558. kiri_mcp/tools/_shared.py +58 -0
  559. kiri_mcp/tools/adapter.py +150 -0
  560. kiri_mcp/tools/cli_reference.py +59 -0
  561. kiri_mcp/tools/compliance.py +119 -0
  562. kiri_mcp/tools/contract.py +374 -0
  563. kiri_mcp/tools/dv.py +62 -0
  564. kiri_mcp/tools/federation.py +152 -0
  565. kiri_mcp/tools/intent.py +71 -0
  566. kiri_mcp/tools/lineage.py +204 -0
  567. kiri_mcp/tools/lineage_drift.py +75 -0
  568. kiri_mcp/tools/plan.py +66 -0
  569. kiri_mcp/tools/project.py +311 -0
  570. kiri_migration/__init__.py +56 -0
  571. kiri_migration/name_preservation.py +271 -0
  572. kiri_migration/predeploy.py +311 -0
  573. kiri_migration/py.typed +0 -0
  574. kiri_migration/reconcile.py +1891 -0
  575. kiri_migration/scaffold/__init__.py +90 -0
  576. kiri_migration/scaffold/_audit_columns.py +74 -0
  577. kiri_migration/scaffold/_classify.py +100 -0
  578. kiri_migration/scaffold/_typemap.py +71 -0
  579. kiri_migration/scaffold/_yaml.py +33 -0
  580. kiri_migration/scaffold/bronze.py +279 -0
  581. kiri_migration/scaffold/compile_targets/__init__.py +271 -0
  582. kiri_migration/scaffold/compile_targets/_common.py +193 -0
  583. kiri_migration/scaffold/compile_targets/copy_into.py +33 -0
  584. kiri_migration/scaffold/compile_targets/dlt.py +125 -0
  585. kiri_migration/scaffold/compile_targets/dlt_with_append_flows.py +163 -0
  586. kiri_migration/scaffold/compile_targets/dlt_with_apply_changes.py +192 -0
  587. kiri_migration/scaffold/compile_targets/dlt_with_expectations.py +276 -0
  588. kiri_migration/scaffold/compile_targets/layer_dispatch.py +258 -0
  589. kiri_migration/scaffold/compile_targets/sql_full_snapshot.py +385 -0
  590. kiri_migration/scaffold/compile_targets/streaming_table.py +37 -0
  591. kiri_migration/scaffold/mart.py +234 -0
  592. kiri_migration/scaffold/plans/__init__.py +49 -0
  593. kiri_migration/scaffold/plans/_registry.py +308 -0
  594. kiri_migration/scaffold/plans/minimal.py +550 -0
  595. kiri_migration/scaffold/plans/plan_b_layer.py +640 -0
  596. kiri_migration/scaffold/silver_from_marts.py +256 -0
  597. kiri_migration/scaffold/silver_pattern.py +451 -0
  598. kiri_migration/sp_translator.py +398 -0
  599. kiri_odcs/LICENSE +204 -0
  600. kiri_odcs/__init__.py +234 -0
  601. kiri_odcs/ai_trust_tier.py +475 -0
  602. kiri_odcs/canonical.py +263 -0
  603. kiri_odcs/diff.py +63 -0
  604. kiri_odcs/errors.py +51 -0
  605. kiri_odcs/invariant_sql.py +136 -0
  606. kiri_odcs/kiri_bridge.py +71 -0
  607. kiri_odcs/kiri_ext.py +3166 -0
  608. kiri_odcs/kiri_fk.py +447 -0
  609. kiri_odcs/kiri_pit.py +121 -0
  610. kiri_odcs/kiri_ref.py +57 -0
  611. kiri_odcs/openlineage_adapter.py +105 -0
  612. kiri_odcs/parser.py +1213 -0
  613. kiri_odcs/pii_guardrails.py +159 -0
  614. kiri_odcs/py.typed +0 -0
  615. kiri_odcs/schema_export.py +223 -0
  616. kiri_openlineage/__init__.py +58 -0
  617. kiri_openlineage/builder.py +146 -0
  618. kiri_openlineage/cost.py +190 -0
  619. kiri_openlineage/emitter.py +165 -0
  620. kiri_openlineage/py.typed +0 -0
  621. kiri_openlineage/schema.py +121 -0
  622. kiri_packs/__init__.py +67 -0
  623. kiri_packs/installer.py +500 -0
  624. kiri_packs/manifest.py +134 -0
  625. kiri_packs/publisher.py +605 -0
  626. kiri_packs/py.typed +0 -0
  627. kiri_packs/validator.py +295 -0
  628. kiri_quality/__init__.py +108 -0
  629. kiri_quality/ai_audit_correlator.py +293 -0
  630. kiri_quality/ai_audit_correlator_apply.py +526 -0
  631. kiri_quality/conformance_evidence.py +272 -0
  632. kiri_quality/dbt_test_collector.py +214 -0
  633. kiri_quality/dq_codegen.py +300 -0
  634. kiri_quality/dv_checks.py +393 -0
  635. kiri_quality/dv_column_lineage.py +279 -0
  636. kiri_quality/dv_column_lineage_apply.py +856 -0
  637. kiri_quality/dv_conformance.py +841 -0
  638. kiri_quality/dv_fixture_runner.py +478 -0
  639. kiri_quality/dv_invariants.py +986 -0
  640. kiri_quality/dv_kiri.py +97 -0
  641. kiri_quality/dv_markdown.py +169 -0
  642. kiri_quality/dv_observed_uc_reader.py +187 -0
  643. kiri_quality/dv_runner.py +389 -0
  644. kiri_quality/evidence_status.py +337 -0
  645. kiri_quality/fk_checks.py +800 -0
  646. kiri_quality/fk_ddl.py +402 -0
  647. kiri_quality/fk_pit_contract.py +168 -0
  648. kiri_quality/fk_runner.py +950 -0
  649. kiri_quality/kernel.py +376 -0
  650. kiri_quality/pii.py +60 -0
  651. kiri_quality/pre_classify/__init__.py +55 -0
  652. kiri_quality/pre_classify/engine.py +346 -0
  653. kiri_quality/pre_classify/locale_packs/en.yaml +117 -0
  654. kiri_quality/pre_classify/locale_packs/sv.yaml +73 -0
  655. kiri_quality/pre_classify/merge.py +297 -0
  656. kiri_quality/pre_classify/model.py +163 -0
  657. kiri_quality/py.typed +0 -0
  658. kiri_quality/reject_lane.py +261 -0
  659. kiri_quality/rule_runner.py +565 -0
  660. kiri_quality/schema_evolution_check.py +88 -0
  661. kiri_quality/silver_quality.py +494 -0
  662. kiri_quality/silver_quality_markdown.py +187 -0
  663. kiri_quality/uc_security_compile.py +951 -0
  664. kiri_quality/uc_security_compile_apply.py +1186 -0
  665. kiri_rbac/__init__.py +87 -0
  666. kiri_rbac/access.py +371 -0
  667. kiri_rbac/loader.py +104 -0
  668. kiri_rbac/pr_gate.py +213 -0
  669. kiri_rbac/providers/__init__.py +29 -0
  670. kiri_rbac/providers/_jwt.py +121 -0
  671. kiri_rbac/providers/azure_ad.py +93 -0
  672. kiri_rbac/providers/base.py +107 -0
  673. kiri_rbac/providers/factory.py +48 -0
  674. kiri_rbac/providers/github.py +134 -0
  675. kiri_rbac/providers/oidc_generic.py +99 -0
  676. kiri_rbac/py.typed +0 -0
  677. kiri_rbac/resolver.py +118 -0
  678. kiri_rbac/schema.py +219 -0
  679. kiri_schema_registry/__init__.py +22 -0
  680. kiri_schema_registry/avro.py +127 -0
  681. kiri_schema_registry/base.py +71 -0
  682. kiri_schema_registry/confluent.py +184 -0
  683. kiri_schema_registry/glue.py +178 -0
  684. kiri_schema_registry/py.typed +0 -0
  685. kiri_slack_bot/__init__.py +24 -0
  686. kiri_slack_bot/handler.py +248 -0
  687. kiri_slack_bot/py.typed +0 -0
  688. kiri_slack_bot/queries.py +90 -0
  689. kiri_slack_bot/signature.py +94 -0
  690. kiri_vault/__init__.py +59 -0
  691. kiri_vault/audit.py +64 -0
  692. kiri_vault/aws_vault.py +123 -0
  693. kiri_vault/azure_vault.py +236 -0
  694. kiri_vault/boundaries.py +219 -0
  695. kiri_vault/client.py +54 -0
  696. kiri_vault/databricks_vault.py +292 -0
  697. kiri_vault/env_vault.py +77 -0
  698. kiri_vault/py.typed +0 -0
  699. kiri_vault/ref.py +84 -0
  700. kirimana_automation/__init__.py +0 -0
  701. kirimana_automation/business_calendar.py +179 -0
  702. kirimana_automation/dag_snapshot.py +252 -0
  703. kirimana_automation/db/__init__.py +5 -0
  704. kirimana_automation/db/migrate.py +571 -0
  705. kirimana_automation/db/migrations/0001_release_state.down.duckdb.sql +29 -0
  706. kirimana_automation/db/migrations/0001_release_state.down.postgres.sql +37 -0
  707. kirimana_automation/db/migrations/0001_release_state.duckdb.sql +170 -0
  708. kirimana_automation/db/migrations/0001_release_state.postgres.sql +176 -0
  709. kirimana_automation/db/migrations/0002_rate_limit_buckets.down.duckdb.sql +9 -0
  710. kirimana_automation/db/migrations/0002_rate_limit_buckets.down.postgres.sql +9 -0
  711. kirimana_automation/db/migrations/0002_rate_limit_buckets.duckdb.sql +14 -0
  712. kirimana_automation/db/migrations/0002_rate_limit_buckets.postgres.sql +18 -0
  713. kirimana_automation/db/migrations/0003_flow_runs.down.duckdb.sql +18 -0
  714. kirimana_automation/db/migrations/0003_flow_runs.down.postgres.sql +19 -0
  715. kirimana_automation/db/migrations/0003_flow_runs.duckdb.sql +85 -0
  716. kirimana_automation/db/migrations/0003_flow_runs.postgres.sql +102 -0
  717. kirimana_automation/db/migrations/0004_flow_dag_snapshots.down.duckdb.sql +11 -0
  718. kirimana_automation/db/migrations/0004_flow_dag_snapshots.down.postgres.sql +11 -0
  719. kirimana_automation/db/migrations/0004_flow_dag_snapshots.duckdb.sql +15 -0
  720. kirimana_automation/db/migrations/0004_flow_dag_snapshots.postgres.sql +31 -0
  721. kirimana_automation/db/migrations/0005_calendar_skip.down.duckdb.sql +109 -0
  722. kirimana_automation/db/migrations/0005_calendar_skip.down.postgres.sql +42 -0
  723. kirimana_automation/db/migrations/0005_calendar_skip.duckdb.sql +113 -0
  724. kirimana_automation/db/migrations/0005_calendar_skip.postgres.sql +42 -0
  725. kirimana_automation/db/migrations/0006_flow_apply_log.down.duckdb.sql +9 -0
  726. kirimana_automation/db/migrations/0006_flow_apply_log.down.postgres.sql +11 -0
  727. kirimana_automation/db/migrations/0006_flow_apply_log.duckdb.sql +45 -0
  728. kirimana_automation/db/migrations/0006_flow_apply_log.postgres.sql +49 -0
  729. kirimana_automation/db/migrations/0007_flow_task_error_redacted.down.duckdb.sql +14 -0
  730. kirimana_automation/db/migrations/0007_flow_task_error_redacted.down.postgres.sql +2 -0
  731. kirimana_automation/db/migrations/0007_flow_task_error_redacted.duckdb.sql +7 -0
  732. kirimana_automation/db/migrations/0007_flow_task_error_redacted.postgres.sql +11 -0
  733. kirimana_automation/db/migrations/README.md +84 -0
  734. kirimana_automation/flow_loader.py +220 -0
  735. kirimana_automation/freeze.py +133 -0
  736. kirimana_automation/kiri_recommender.py +313 -0
  737. kirimana_automation/main.py +195 -0
  738. kirimana_automation/py.typed +0 -0
  739. kirimana_automation/run_history.py +593 -0
  740. kirimana_automation/run_history_analysis.py +607 -0
  741. kirimana_automation/schedule.py +343 -0
  742. kirimana_automation/schedule_validator.py +572 -0
  743. kirimana_automation/workflow_compiler.py +1288 -0
@@ -0,0 +1,2980 @@
1
+ """Databricks adapter.
2
+
3
+ Talks to Databricks via the Workspace SDK + Statement Execution API.
4
+
5
+ Two ingestion modes, matching the table's metadata:
6
+
7
+ - **fixture_file** — legacy demo path. A single CSV per table is uploaded to a
8
+ Unity Catalog volume and appended via ``COPY INTO``.
9
+ - **landing_zone** — the production path (ADR 0008 + 0009 + 0046). Files
10
+ reach the landing storage primitive (ADLS in production, LocalLandingStorage
11
+ in dev/tests). Per file we ``move_to_raw`` (atomic on HNS-enabled ADLS)
12
+ and run a native SQL ``MERGE`` with ``_kiri_*`` columns injected by a
13
+ SELECT wrapper. The warehouse reads bytes directly from the storage URL
14
+ (``read_files('abfss://...')``) — no pod-mediated upload step.
15
+
16
+ Required environment / profile (landing_zone):
17
+ - ``DATABRICKS_HOST`` (profile ``host`` overrides)
18
+ - ``DATABRICKS_TOKEN``
19
+ - ``DATABRICKS_WAREHOUSE_ID`` or profile ``warehouse_id``
20
+ - profile ``catalog`` (default: ``main``)
21
+ - profile ``ingest_volume`` (default: ``main.ops.ingest`` — used by
22
+ fixture mode only)
23
+ - profile ``landing_storage`` block (ADR 0046 phase 2) — points at ADLS in
24
+ production. When omitted, the adapter falls back to
25
+ :class:`LocalLandingStorage` for development; warehouse-side read of
26
+ ``file://`` URLs requires a colocated runtime, so production deploys
27
+ must configure the block.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import logging
34
+ import os
35
+ import re
36
+ import time
37
+ import uuid
38
+ from contextlib import suppress
39
+ from dataclasses import dataclass
40
+ from datetime import UTC, date, datetime
41
+ from pathlib import Path
42
+ from typing import TYPE_CHECKING, Any, Literal
43
+
44
+ from kiri_core.ai_query_gate.audit import _redact_preview
45
+ from kiri_core.schema import IngestionMode, Source
46
+ from kiri_core.trace import current_trace
47
+ from kiri_core.types import HealthStatus, RunStatus
48
+
49
+ from kiri_adapters.base import (
50
+ ApplyLogEntry,
51
+ AssetWatermark,
52
+ ColumnDescription,
53
+ Credential,
54
+ IngestionPlan,
55
+ LiveColumnAnnotation,
56
+ LiveTableAnnotations,
57
+ NamingLockError,
58
+ PlatformAdapter,
59
+ ProjectManifest,
60
+ ProjectTargetCollisionError,
61
+ QueryResult,
62
+ ReleaseManifestEntry,
63
+ RetentionAction,
64
+ RetentionReport,
65
+ RunResult,
66
+ StorageResolution,
67
+ TableDescription,
68
+ TableRef,
69
+ WatermarkStoreError,
70
+ )
71
+ from kiri_adapters.sql_dialect import DeltaDialect, SqlDialect
72
+
73
+ if TYPE_CHECKING: # pragma: no cover — type-only to avoid import cost at runtime
74
+ from kiri_odcs.kiri_ext import StorageBinding
75
+
76
+ _logger = logging.getLogger(__name__)
77
+
78
+ DEFAULT_CATALOG = "main"
79
+ DEFAULT_INGEST_VOLUME = "main.ops.ingest"
80
+
81
+ # ADR 0014 / 0017: reserved schema + tables for governance metadata.
82
+ # Live under the project's Unity Catalog, keeping all kiri.* state
83
+ # under one ACL boundary.
84
+ _META_SCHEMA = "_kiri_meta"
85
+ _MANIFEST_TABLE = "_kiri_project_manifest"
86
+ _APPLY_LOG_TABLE = "_kiri_apply_log"
87
+ # ADR 0041: release-manifest mirror — three tables alongside the apply log.
88
+ _RELEASE_STATE_TABLE = "_kiri_release_state"
89
+ _ACTIVE_CONTRACT_VERSIONS_TABLE = "_kiri_active_contract_versions"
90
+ _PROMOTION_HISTORY_TABLE = "_kiri_promotion_history"
91
+ # ADR 0080: asset-level watermark store — one row per (asset_urn, target_env).
92
+ _ASSET_WATERMARKS_TABLE = "_kiri_asset_watermarks"
93
+
94
+ _TRUTHY_AUDIT = {"1", "on", "true"}
95
+ _FALSY_AUDIT = {"0", "off", "false"}
96
+ _TRACE_COMMENT_PREFIX = "/* kiri_trace_id="
97
+ _SQL_TOKEN_RE = re.compile(
98
+ r"`(?:``|[^`])+`|\"(?:\"\"|[^\"])+\"|'(?:''|[^'])+'|[A-Za-z_][\w$]*|[.,();]"
99
+ )
100
+ _WITH_TOP_LEVEL_VERBS = {"SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "CREATE"}
101
+ _AUDIT_DISABLED_DESTINATIONS: set[tuple[str, str]] = set()
102
+ _McpAuditStatus = Literal["OK", "FAILED", "DENIED", "success", "failed"]
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class _AuditPaths:
107
+ jsonl: Path
108
+ line: Path
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class _AuditRecord:
113
+ ts: str
114
+ trace_id: str
115
+ apply_id: str | None
116
+ caller: str
117
+ via: str
118
+ user: str
119
+ event: str
120
+ target: str
121
+ duration_ms: int
122
+ attempts: int
123
+ status: str
124
+ warehouse_id: object
125
+ statement_preview: str
126
+ error_class: str | None = None
127
+ reason: str | None = None
128
+
129
+
130
+ # ---- pure SQL builders (tested in isolation) ------------------------------
131
+
132
+
133
+ def _quote_ident(name: str) -> str:
134
+ """Databricks backtick-quoting."""
135
+ return "`" + name.replace("`", "``") + "`"
136
+
137
+
138
+ def _fq(catalog: str, schema: str, table: str) -> str:
139
+ return f"{_quote_ident(catalog)}.{_quote_ident(schema)}.{_quote_ident(table)}"
140
+
141
+
142
+ def _is_table_or_schema_not_found(exc: BaseException) -> bool:
143
+ """True when ``exc`` reports a missing table/view/schema (not a real fault).
144
+
145
+ Databricks/Spark surface absence through the SQL error message rather than a
146
+ distinct exception type, so we match the stable error-class tokens
147
+ (``TABLE_OR_VIEW_NOT_FOUND`` / ``SCHEMA_NOT_FOUND`` and the
148
+ ``... does not exist`` / ``cannot be found`` phrasings). Used by
149
+ :meth:`read_watermark` to map only genuine absence to ``None`` while every
150
+ other failure raises :class:`WatermarkStoreError` (ADR 0080 §2 — a real read
151
+ error must never look like an empty cursor).
152
+
153
+ Delegates to the cross-dialect :func:`kiri_adapters.base.is_relation_absent_error`
154
+ so the not-found token logic lives in ONE place (shared with the DV freeze
155
+ fail-closed path, ADR 0080 §4); the Databricks-specific narrowing is the
156
+ same set of stable Spark error-class tokens.
157
+ """
158
+ from kiri_adapters.base import is_relation_absent_error
159
+
160
+ return is_relation_absent_error(exc)
161
+
162
+
163
+ def _sql_str_lit(value: str) -> str:
164
+ """Single-quote a SQL string literal, escaping embedded quotes."""
165
+ return "'" + value.replace("'", "''") + "'"
166
+
167
+
168
+ def _render_describe_external_location(name: str) -> str:
169
+ """ADR 0132 §3 read primitive: ``DESCRIBE EXTERNAL LOCATION `<name>` ``.
170
+
171
+ Resolves the location NAME → its ``url`` + ``credential_name`` (and owner).
172
+ """
173
+ return f"DESCRIBE EXTERNAL LOCATION {_quote_ident(name)}"
174
+
175
+
176
+ def _render_create_external_location(name: str, url: str, credential: str) -> str:
177
+ """ADR 0132 §3 provision DDL: idempotent ``CREATE EXTERNAL LOCATION``.
178
+
179
+ UC requires ``WITH (STORAGE CREDENTIAL <name>)``; the credential name is
180
+ backtick-quoted, the url single-quote-escaped.
181
+ """
182
+ return (
183
+ f"CREATE EXTERNAL LOCATION IF NOT EXISTS {_quote_ident(name)} "
184
+ f"URL {_sql_str_lit(url)} "
185
+ f"WITH (STORAGE CREDENTIAL {_quote_ident(credential)})"
186
+ )
187
+
188
+
189
+ def _render_create_schema_managed_location(catalog: str, schema: str, location: str) -> str:
190
+ """ADR 0132 §3 provision DDL: idempotent per-schema ``MANAGED LOCATION``."""
191
+ return (
192
+ f"CREATE SCHEMA IF NOT EXISTS "
193
+ f"{_quote_ident(catalog)}.{_quote_ident(schema)} "
194
+ f"MANAGED LOCATION {_sql_str_lit(location)}"
195
+ )
196
+
197
+
198
+ # Issue #366 — ADR 0124 renamed meta columns ``_dca_*`` → ``_kiri_*``.
199
+ # A target table created by a pre-rename build still carries the old
200
+ # names; the rewritten ``build_merge_sql`` emits ``_kiri_*`` and
201
+ # ``WHEN MATCHED THEN UPDATE SET *`` expands against the target's
202
+ # columns, so the MERGE fails with ``DELTA_MERGE_UNRESOLVED_EXPRESSION``
203
+ # and the per-file loop swallows it as "0 rows" → silver/gold cascade
204
+ # empty. We detect this state before MERGE and refuse to proceed.
205
+ _STALE_META_PREFIX = "_dca_"
206
+
207
+
208
+ class StaleSchemaError(RuntimeError):
209
+ """Target table predates the ADR 0124 meta-column rename.
210
+
211
+ Auto-migration via ``ALTER TABLE ... RENAME COLUMN`` was rejected:
212
+ Delta rewrites the file footprint on rename and the operation is
213
+ not rollback-safe. Pre-1.0 the breaking-change rename had no
214
+ upgrade script by design (see ADR 0124); this error surfaces the
215
+ one-time mismatch with an actionable hint instead of letting it
216
+ cascade through silver and gold as empty rows.
217
+ """
218
+
219
+ def __init__(self, target_fq: str, stale_columns: list[str]) -> None:
220
+ self.target_fq = target_fq
221
+ self.stale_columns = list(stale_columns)
222
+ rename_hints = "; ".join(
223
+ f"ALTER TABLE {target_fq} RENAME COLUMN {col} TO "
224
+ f"{col.replace(_STALE_META_PREFIX, '_kiri_', 1)}"
225
+ for col in stale_columns
226
+ )
227
+ super().__init__(
228
+ f"Target {target_fq} has pre-ADR-0124 meta columns "
229
+ f"({', '.join(stale_columns)}). ADR 0124 renamed _dca_* → "
230
+ f"_kiri_*; the MERGE cannot reconcile. Drop and recreate "
231
+ f"the table (DROP TABLE {target_fq}) before re-running "
232
+ f"apply, or rename the columns explicitly: "
233
+ f"{rename_hints}."
234
+ )
235
+
236
+
237
+ def _stale_meta_columns_from_describe(
238
+ describe_rows: list[tuple[Any, ...]] | None,
239
+ ) -> list[str]:
240
+ """Return pre-rename ``_dca_*`` column names from ``DESCRIBE TABLE`` output.
241
+
242
+ Databricks' ``DESCRIBE TABLE`` returns ``(col_name, data_type,
243
+ comment)`` rows for each column, then a delimiter (empty
244
+ ``col_name``) and a ``# Detailed Table Information`` section.
245
+ Stop at the delimiter — only column rows precede.
246
+ """
247
+ if not describe_rows:
248
+ return []
249
+ stale: list[str] = []
250
+ for row in describe_rows:
251
+ if not row:
252
+ continue
253
+ col_name = str(row[0])
254
+ # Delimiter row (blank) or section header ("# Detailed Table
255
+ # Information", "# Partition Information", …) marks end of
256
+ # the column list.
257
+ if not col_name.strip() or col_name.startswith("#"):
258
+ break
259
+ if col_name.startswith(_STALE_META_PREFIX):
260
+ stale.append(col_name)
261
+ return stale
262
+
263
+
264
+ def _strip_leading_trace_comment(sql: str) -> str:
265
+ body = sql.strip()
266
+ if body.startswith(_TRACE_COMMENT_PREFIX):
267
+ end = body.find("*/")
268
+ if end != -1:
269
+ return body[end + 2 :].strip()
270
+ return body
271
+
272
+
273
+ def _normalise_identifier(token: str) -> str:
274
+ token = token.rstrip(",;")
275
+ if len(token) >= 2 and token[0] == "`" and token[-1] == "`":
276
+ return token[1:-1].replace("``", "`")
277
+ if len(token) >= 2 and token[0] == '"' and token[-1] == '"':
278
+ return token[1:-1].replace('""', '"')
279
+ return token
280
+
281
+
282
+ def _sql_tokens(sql: str) -> list[str]:
283
+ return [m.group(0) for m in _SQL_TOKEN_RE.finditer(_strip_leading_trace_comment(sql))]
284
+
285
+
286
+ def _keyword(token: str) -> str:
287
+ if token.startswith(("`", '"', "'")):
288
+ return ""
289
+ return token.upper()
290
+
291
+
292
+ def _read_identifier(tokens: list[str], start: int) -> str:
293
+ parts: list[str] = []
294
+ i = start
295
+ while i < len(tokens):
296
+ tok = tokens[i]
297
+ if tok == ".":
298
+ i += 1
299
+ continue
300
+ if tok in {"(", ")", ",", ";"}:
301
+ break
302
+ if _keyword(tok) in {
303
+ "AS",
304
+ "USING",
305
+ "WHERE",
306
+ "SET",
307
+ "VALUES",
308
+ "SELECT",
309
+ "COMMENT",
310
+ "LOCATION",
311
+ "PARTITIONED",
312
+ "TBLPROPERTIES",
313
+ "WITH",
314
+ "FROM",
315
+ "TO",
316
+ "ON",
317
+ }:
318
+ break
319
+ if tok.startswith("'"):
320
+ break
321
+ parts.append(_normalise_identifier(tok))
322
+ if i + 1 >= len(tokens) or tokens[i + 1] != ".":
323
+ break
324
+ i += 2
325
+ return ".".join(p for p in parts if p) or "?"
326
+
327
+
328
+ def _skip_if_not_exists(words: list[str], start: int) -> int:
329
+ if words[start : start + 3] == ["IF", "NOT", "EXISTS"]:
330
+ return start + 3
331
+ return start
332
+
333
+
334
+ def _skip_optional_table(words: list[str], start: int) -> int:
335
+ if start < len(words) and words[start] == "TABLE":
336
+ return start + 1
337
+ return start
338
+
339
+
340
+ def _cte_key(name: str) -> str:
341
+ return _normalise_identifier(name).lower()
342
+
343
+
344
+ def _with_ctes_and_top_level_verb(tokens: list[str]) -> tuple[dict[str, list[str]], int | None]:
345
+ ctes: dict[str, list[str]] = {}
346
+ i = 1
347
+ if i < len(tokens) and _keyword(tokens[i]) == "RECURSIVE":
348
+ i += 1
349
+ while i < len(tokens):
350
+ name = tokens[i]
351
+ if name in {"(", ")", ",", ";"} or _keyword(name) in _WITH_TOP_LEVEL_VERBS | {"AS"}:
352
+ break
353
+ cte_name = _cte_key(name)
354
+ i += 1
355
+
356
+ if i < len(tokens) and tokens[i] == "(":
357
+ depth = 1
358
+ i += 1
359
+ while i < len(tokens) and depth > 0:
360
+ if tokens[i] == "(":
361
+ depth += 1
362
+ elif tokens[i] == ")":
363
+ depth -= 1
364
+ i += 1
365
+
366
+ if i >= len(tokens) or _keyword(tokens[i]) != "AS":
367
+ break
368
+ i += 1
369
+ if i >= len(tokens) or tokens[i] != "(":
370
+ break
371
+ body_start = i + 1
372
+ depth = 1
373
+ i += 1
374
+ while i < len(tokens) and depth > 0:
375
+ if tokens[i] == "(":
376
+ depth += 1
377
+ elif tokens[i] == ")":
378
+ depth -= 1
379
+ i += 1
380
+ if depth != 0:
381
+ return ctes, None
382
+ ctes[cte_name] = tokens[body_start : i - 1]
383
+
384
+ if i < len(tokens) and tokens[i] == ",":
385
+ i += 1
386
+ continue
387
+ break
388
+
389
+ for j in range(i, len(tokens)):
390
+ if _keyword(tokens[j]) in _WITH_TOP_LEVEL_VERBS:
391
+ return ctes, j
392
+ return ctes, None
393
+
394
+
395
+ def _audit_event_from_tokens(tokens: list[str], start: int) -> str:
396
+ words = [_keyword(t) for t in tokens]
397
+ if start >= len(words) or not words[start]:
398
+ return "OTHER"
399
+ first = words[start]
400
+ if first == "CREATE":
401
+ i = start + 1
402
+ while i < len(words) and words[i] in {
403
+ "OR",
404
+ "REPLACE",
405
+ "TEMP",
406
+ "TEMPORARY",
407
+ "EXTERNAL",
408
+ "LIVE",
409
+ "STREAMING",
410
+ }:
411
+ i += 1
412
+ if i < len(words) and words[i] in {"CATALOG", "SCHEMA", "DATABASE", "TABLE", "VIEW"}:
413
+ kind = "SCHEMA" if words[i] == "DATABASE" else words[i]
414
+ return f"CREATE_{kind}"
415
+ return "CREATE"
416
+ if first in {"DROP", "ALTER"} and start + 1 < len(words):
417
+ kind = "SCHEMA" if words[start + 1] == "DATABASE" else words[start + 1]
418
+ if kind in {"CATALOG", "SCHEMA", "TABLE", "VIEW"}:
419
+ return f"{first}_{kind}"
420
+ if first == "DESCRIBE":
421
+ return "DESCRIBE"
422
+ if first in {
423
+ "INSERT",
424
+ "MERGE",
425
+ "UPDATE",
426
+ "DELETE",
427
+ "SELECT",
428
+ "SHOW",
429
+ "GRANT",
430
+ "REVOKE",
431
+ "OPTIMIZE",
432
+ "VACUUM",
433
+ "TRUNCATE",
434
+ "REFRESH",
435
+ }:
436
+ return first
437
+ return "OTHER"
438
+
439
+
440
+ def databricks_audit_event(sql: str) -> str:
441
+ """Derive ADR 0092 event name from a Databricks SQL statement."""
442
+ tokens = _sql_tokens(sql)
443
+ if not tokens:
444
+ return "OTHER"
445
+ if _keyword(tokens[0]) == "WITH":
446
+ _, start = _with_ctes_and_top_level_verb(tokens)
447
+ return "OTHER" if start is None else _audit_event_from_tokens(tokens, start)
448
+ return _audit_event_from_tokens(tokens, 0)
449
+
450
+
451
+ def _audit_target_from_tokens(tokens: list[str], start: int) -> str:
452
+ words = [_keyword(t) for t in tokens]
453
+ if start >= len(words):
454
+ return "?"
455
+ first = words[start]
456
+ if first == "CREATE":
457
+ i = start + 1
458
+ while i < len(words) and words[i] in {
459
+ "OR",
460
+ "REPLACE",
461
+ "TEMP",
462
+ "TEMPORARY",
463
+ "EXTERNAL",
464
+ "LIVE",
465
+ "STREAMING",
466
+ }:
467
+ i += 1
468
+ if i < len(words) and words[i] in {"CATALOG", "SCHEMA", "DATABASE", "TABLE", "VIEW"}:
469
+ return _read_identifier(tokens, _skip_if_not_exists(words, i + 1))
470
+ if first in {"DROP", "ALTER"} and start + 2 < len(tokens):
471
+ return _read_identifier(tokens, start + 2)
472
+ if first == "INSERT":
473
+ for i in range(start + 1, len(words)):
474
+ word = words[i]
475
+ if word in {"INTO", "OVERWRITE"}:
476
+ return _read_identifier(tokens, _skip_optional_table(words, i + 1))
477
+ if first == "MERGE" and start + 1 < len(words) and words[start + 1] == "INTO":
478
+ return _read_identifier(tokens, start + 2)
479
+ if first == "UPDATE":
480
+ return _read_identifier(tokens, start + 1)
481
+ if first == "DELETE":
482
+ target_start = (
483
+ start + 2 if start + 1 < len(words) and words[start + 1] == "FROM" else start + 1
484
+ )
485
+ return _read_identifier(tokens, target_start)
486
+ if first in {"DESCRIBE", "TRUNCATE", "REFRESH"}:
487
+ target_start = (
488
+ start + 2
489
+ if start + 1 < len(words)
490
+ and words[start + 1] in {"TABLE", "VIEW", "EXTENDED", "FORMATTED"}
491
+ else start + 1
492
+ )
493
+ return _read_identifier(tokens, target_start)
494
+ if first == "SELECT":
495
+ depth = 0
496
+ for i in range(start + 1, len(words)):
497
+ tok = tokens[i]
498
+ if tok == "(":
499
+ depth += 1
500
+ continue
501
+ if tok == ")":
502
+ depth = max(0, depth - 1)
503
+ continue
504
+ word = words[i]
505
+ if word == "FROM":
506
+ if depth > 0:
507
+ continue
508
+ return _read_identifier(tokens, i + 1)
509
+ if first == "GRANT":
510
+ for i in range(start + 1, len(words)):
511
+ word = words[i]
512
+ if word == "ON":
513
+ start = i + 2 if i + 2 < len(tokens) else i + 1
514
+ return _read_identifier(tokens, start)
515
+ if first == "SHOW":
516
+ if "IN" in words[start + 1 :]:
517
+ return _read_identifier(tokens, words.index("IN", start + 1) + 1)
518
+ if "FROM" in words[start + 1 :]:
519
+ return _read_identifier(tokens, words.index("FROM", start + 1) + 1)
520
+ return "?"
521
+
522
+
523
+ def _resolve_cte_audit_target(
524
+ target: str,
525
+ ctes: dict[str, list[str]],
526
+ *,
527
+ via_cte: bool = False,
528
+ seen: set[str] | None = None,
529
+ ) -> str:
530
+ if target == "?":
531
+ return "?"
532
+ seen = set() if seen is None else seen
533
+ current = target
534
+ for _ in range(len(ctes)):
535
+ key = _cte_key(current)
536
+ if key in seen:
537
+ return "?"
538
+ body = ctes.get(key)
539
+ if body is None:
540
+ return current if not via_cte or "." in current else "?"
541
+ seen.add(key)
542
+ if body and _keyword(body[0]) == "WITH":
543
+ nested_ctes, start = _with_ctes_and_top_level_verb(body)
544
+ if start is None:
545
+ return "?"
546
+ nested_target = _audit_target_from_tokens(body, start)
547
+ resolved_nested = _resolve_cte_audit_target(
548
+ nested_target,
549
+ nested_ctes,
550
+ via_cte=True,
551
+ )
552
+ current = resolved_nested
553
+ else:
554
+ current = _audit_target_from_tokens(body, 0)
555
+ if current == "?":
556
+ return "?"
557
+ return current if "." in current else "?"
558
+
559
+
560
+ def databricks_audit_target(sql: str) -> str:
561
+ """Best-effort target object extraction for the ADR 0092 one-liner."""
562
+
563
+ tokens = _sql_tokens(sql)
564
+ if not tokens:
565
+ return "?"
566
+ if _keyword(tokens[0]) == "WITH":
567
+ ctes, start = _with_ctes_and_top_level_verb(tokens)
568
+ if start is None:
569
+ return "?"
570
+ return _resolve_cte_audit_target(_audit_target_from_tokens(tokens, start), ctes)
571
+ return _audit_target_from_tokens(tokens, 0)
572
+
573
+
574
+ def databricks_audit_enabled(profile: dict[str, Any]) -> tuple[bool, str | None]:
575
+ """Resolve ADR 0092 Databricks audit enablement.
576
+
577
+ ``KIRIMANA_DATABRICKS_AUDIT`` is the runtime override. The profile value is
578
+ loader-threaded from ``audit.databricks`` and defaults to enabled.
579
+ """
580
+
581
+ raw_env = os.environ.get("KIRIMANA_DATABRICKS_AUDIT")
582
+ if raw_env is not None:
583
+ value = raw_env.strip().lower()
584
+ if value in _TRUTHY_AUDIT:
585
+ return True, None
586
+ if value in _FALSY_AUDIT:
587
+ return False, "env:KIRIMANA_DATABRICKS_AUDIT"
588
+ raw_profile = profile.get("databricks_audit_enabled", True)
589
+ if isinstance(raw_profile, str):
590
+ value = raw_profile.strip().lower()
591
+ if value in _FALSY_AUDIT:
592
+ return False, "config:audit.databricks=false"
593
+ if value in _TRUTHY_AUDIT:
594
+ return True, None
595
+ if raw_profile is False:
596
+ return False, "config:audit.databricks=false"
597
+ return True, None
598
+
599
+
600
+ def _line_status(status: str, error_class: str | None) -> str:
601
+ if status == "denied":
602
+ return "DENIED"
603
+ if error_class and error_class.lower() in {"permissiondenied", "permission_denied", "denied"}:
604
+ return "DENIED"
605
+ return "OK" if status == "success" else "FAILED"
606
+
607
+
608
+ def _kv(value: object) -> str:
609
+ text = "?" if value is None else str(value)
610
+ return text.replace("\n", " ").replace("\r", " ").replace(" ", "_") or "?"
611
+
612
+
613
+ def _render_audit_line(record: _AuditRecord) -> str:
614
+ parts = [
615
+ record.ts,
616
+ f"user={_kv(record.user)}",
617
+ f"event={_kv(record.event)}",
618
+ f"target={_kv(record.target)}",
619
+ f"status={_line_status(record.status, record.error_class)}",
620
+ f"dur={record.duration_ms / 1000:.1f}s",
621
+ f"trace={_kv(record.trace_id)}",
622
+ f"warehouse={_kv(record.warehouse_id)}",
623
+ f"via={_kv(record.via)}",
624
+ ]
625
+ if record.reason is not None:
626
+ parts.append(f"reason={_kv(record.reason)}")
627
+ return " ".join(parts)
628
+
629
+
630
+ def _resolve_databricks_audit_paths(profile: dict[str, Any]) -> _AuditPaths:
631
+ explicit = profile.get("databricks_audit_log_path")
632
+ if explicit:
633
+ jsonl = Path(str(explicit))
634
+ return _AuditPaths(jsonl=jsonl, line=jsonl.with_name("databricks-audit.log"))
635
+ default_dir = Path.cwd() / "logs" / "databricks-audit"
636
+ base = Path(str(profile.get("databricks_audit_log_dir") or default_dir))
637
+ return _AuditPaths(
638
+ jsonl=base / "databricks-audit.jsonl",
639
+ line=base / "databricks-audit.log",
640
+ )
641
+
642
+
643
+ def _audit_destination_key(paths: _AuditPaths) -> tuple[str, str]:
644
+ return (str(paths.jsonl.resolve()), str(paths.line.resolve()))
645
+
646
+
647
+ def _claim_audit_disabled_emit(paths: _AuditPaths) -> bool:
648
+ key = _audit_destination_key(paths)
649
+ if key in _AUDIT_DISABLED_DESTINATIONS:
650
+ return False
651
+ _AUDIT_DISABLED_DESTINATIONS.add(key)
652
+ return True
653
+
654
+
655
+ def _build_audit_record(
656
+ *,
657
+ event: str,
658
+ target: str,
659
+ status: str,
660
+ duration_ms: int,
661
+ attempts: int,
662
+ warehouse_id: str | None,
663
+ via: str,
664
+ user: str | None,
665
+ error_class: str | None,
666
+ reason: str | None,
667
+ statement_preview: str,
668
+ ) -> _AuditRecord:
669
+ ctx = current_trace()
670
+ return _AuditRecord(
671
+ ts=datetime.now(UTC).isoformat(timespec="seconds"),
672
+ trace_id=ctx.kiri_trace_id,
673
+ apply_id=ctx.apply_id,
674
+ caller=ctx.caller,
675
+ via=via,
676
+ user=user or "unknown",
677
+ event=event,
678
+ target=target,
679
+ duration_ms=duration_ms,
680
+ attempts=attempts,
681
+ status=status,
682
+ warehouse_id=warehouse_id,
683
+ statement_preview=statement_preview,
684
+ error_class=error_class,
685
+ reason=reason,
686
+ )
687
+
688
+
689
+ def _write_audit_line(paths: _AuditPaths, record: _AuditRecord) -> None:
690
+ path = paths.line
691
+ path.parent.mkdir(parents=True, exist_ok=True)
692
+ with path.open("a", encoding="utf-8") as f:
693
+ f.write(_render_audit_line(record) + "\n")
694
+
695
+
696
+ def _write_audit_jsonl(paths: _AuditPaths, record: _AuditRecord) -> None:
697
+ path = paths.jsonl
698
+ path.parent.mkdir(parents=True, exist_ok=True)
699
+ # Keep existing JSONL keys and status vocabulary for audit_join
700
+ # back-compat. ADR 0092's OK/FAILED/DENIED vocabulary is only
701
+ # the one-line projection.
702
+ row: dict[str, Any] = {
703
+ "ts": record.ts,
704
+ "trace_id": record.trace_id,
705
+ "apply_id": record.apply_id,
706
+ "caller": record.caller,
707
+ "duration_ms": record.duration_ms,
708
+ "attempts": record.attempts,
709
+ "status": "failed" if record.status == "denied" else record.status,
710
+ "warehouse_id": record.warehouse_id,
711
+ "statement_preview": record.statement_preview,
712
+ "user": record.user,
713
+ "event": record.event,
714
+ "via": record.via,
715
+ }
716
+ if record.error_class is not None:
717
+ row["error_class"] = record.error_class
718
+ if record.reason is not None:
719
+ row["reason"] = record.reason
720
+ with path.open("a", encoding="utf-8") as f:
721
+ f.write(json.dumps(row, default=str) + "\n")
722
+
723
+
724
+ def _write_audit_record(paths: _AuditPaths, record: _AuditRecord) -> None:
725
+ with suppress(Exception):
726
+ _write_audit_line(paths, record)
727
+ try:
728
+ _write_audit_jsonl(paths, record)
729
+ except Exception:
730
+ # Never break Databricks work because the client-side audit-log
731
+ # filesystem hiccupped. The Databricks-side audit log (ADR 0049) is
732
+ # the authoritative trail; this client-side emission is a complement.
733
+ return
734
+
735
+
736
+ def _emit_databricks_audit_record(
737
+ *,
738
+ profile: dict[str, Any],
739
+ paths: _AuditPaths,
740
+ event: str,
741
+ target: str,
742
+ status: str,
743
+ duration_ms: int,
744
+ attempts: int,
745
+ warehouse_id: str | None,
746
+ via: str,
747
+ user: str | None,
748
+ error_class: str | None,
749
+ statement_preview: str,
750
+ disabled_reason: str | None = None,
751
+ ) -> None:
752
+ enabled, resolved_disabled_reason = databricks_audit_enabled(profile)
753
+ if not enabled:
754
+ if _claim_audit_disabled_emit(paths):
755
+ record = _build_audit_record(
756
+ event="AUDIT_DISABLED",
757
+ target="?",
758
+ status="failed",
759
+ duration_ms=0,
760
+ attempts=0,
761
+ warehouse_id=warehouse_id,
762
+ via=via,
763
+ user=user,
764
+ error_class=None,
765
+ reason=disabled_reason
766
+ or resolved_disabled_reason
767
+ or "config:audit.databricks=false",
768
+ statement_preview=statement_preview,
769
+ )
770
+ _write_audit_record(paths, record)
771
+ return
772
+ record = _build_audit_record(
773
+ event=event,
774
+ target=target,
775
+ status=status,
776
+ duration_ms=duration_ms,
777
+ attempts=attempts,
778
+ warehouse_id=warehouse_id,
779
+ via=via,
780
+ user=user,
781
+ error_class=error_class,
782
+ reason=None,
783
+ statement_preview=statement_preview,
784
+ )
785
+ _write_audit_record(paths, record)
786
+
787
+
788
+ def _normalize_mcp_audit_status(status: str) -> str:
789
+ value = status.strip().lower()
790
+ if value in {"ok", "success"}:
791
+ return "success"
792
+ if value == "denied":
793
+ return "denied"
794
+ return "failed"
795
+
796
+
797
+ def emit_databricks_mcp_audit(
798
+ *,
799
+ event: str,
800
+ target: str,
801
+ status: _McpAuditStatus,
802
+ duration_ms: int,
803
+ warehouse_id: str | None,
804
+ user: str | None = None,
805
+ error_class: str | None = None,
806
+ profile: dict[str, Any] | None = None,
807
+ statement_preview: str | None = None,
808
+ ) -> None:
809
+ """Emit the ADR 0092 audit record at the Kiri→Databricks-MCP boundary."""
810
+
811
+ normalized_status = _normalize_mcp_audit_status(status)
812
+ resolved_profile = profile or {}
813
+ paths = _resolve_databricks_audit_paths(resolved_profile)
814
+ _emit_databricks_audit_record(
815
+ profile=resolved_profile,
816
+ paths=paths,
817
+ event=event,
818
+ target=target,
819
+ status=normalized_status,
820
+ duration_ms=duration_ms,
821
+ attempts=1,
822
+ warehouse_id=warehouse_id,
823
+ via="mcp",
824
+ user=user,
825
+ error_class=error_class,
826
+ statement_preview=_redact_preview(statement_preview or f"{event} {target}"),
827
+ )
828
+
829
+
830
+ def _extract_current_user(response: Any) -> str | None:
831
+ data = getattr(getattr(response, "result", None), "data_array", None)
832
+ if not data:
833
+ return None
834
+ first_row = data[0]
835
+ if not first_row:
836
+ return None
837
+ return str(first_row[0])
838
+
839
+
840
+ def build_staging_select_sql(
841
+ *,
842
+ volume_path: str,
843
+ fmt: str,
844
+ source_file: str,
845
+ trace_id: str,
846
+ deleted_flag: str | None,
847
+ today: date,
848
+ json_shape: str = "array",
849
+ ) -> str:
850
+ """SELECT wrapper that reads a file from a volume and adds `_kiri_*` columns.
851
+
852
+ Path, trace_id, source_file are the only user-ish inputs; we embed them as
853
+ string literals (Databricks parameters don't survive inside ``read_files``
854
+ options cleanly). All three come from our own metadata / runtime and
855
+ never from end-user SQL.
856
+
857
+ ``json_shape`` mirrors ``IngestionConfig.json_shape``:
858
+
859
+ - ``array`` / ``single`` → ``multiLine => 'true'`` (Spark reads the whole
860
+ file as one JSON document — array elements become rows, single object
861
+ becomes a one-row table).
862
+ - ``ndjson`` → ``multiLine => 'false'`` (newline-delimited; one row per
863
+ line). This is the shape most integration platforms push.
864
+
865
+ Ignored when ``fmt != "json"``.
866
+ """
867
+ if fmt not in {"json", "csv"}:
868
+ raise ValueError(f"Unsupported fmt: {fmt}")
869
+ if fmt == "json" and json_shape not in {"array", "ndjson", "single"}:
870
+ raise ValueError(
871
+ f"Unsupported json_shape: {json_shape!r}; expected one of 'array', 'ndjson', 'single'."
872
+ )
873
+ # ``recursiveFileLookup => 'true'`` disables Hive-style partition
874
+ # discovery in Databricks' ``read_files``: the raw landing path is
875
+ # ``…/_raw/_kiri_ingested_date=YYYY-MM-DD/<file>``; with discovery
876
+ # on, Spark synthesises an ``_kiri_ingested_date`` partition column
877
+ # that collides with the literal one projected below
878
+ # (AMBIGUOUS_COLUMN_OR_FIELD).
879
+ if fmt == "json":
880
+ multi_line = "false" if json_shape == "ndjson" else "true"
881
+ fmt_opts = f"format => 'json', multiLine => '{multi_line}', recursiveFileLookup => 'true'"
882
+ else:
883
+ fmt_opts = "format => 'csv', header => 'true', recursiveFileLookup => 'true'"
884
+ # Literal escaping — single-quote-in-SQL = double the quote.
885
+ v = volume_path.replace("'", "''")
886
+ sf = source_file.replace("'", "''")
887
+ tid = trace_id.replace("'", "''")
888
+ d_iso = today.isoformat()
889
+
890
+ if deleted_flag:
891
+ tombstone = (
892
+ f"CAST(CASE WHEN CAST({_quote_ident(deleted_flag)} AS BOOLEAN) "
893
+ f"THEN CURRENT_TIMESTAMP() ELSE NULL END AS TIMESTAMP)"
894
+ )
895
+ else:
896
+ tombstone = "CAST(NULL AS TIMESTAMP)"
897
+
898
+ return (
899
+ f"SELECT "
900
+ f"src.*, "
901
+ f"CURRENT_TIMESTAMP() AS _kiri_ingested_at, "
902
+ f"'{sf}' AS _kiri_source_file, "
903
+ f"'{tid}' AS _kiri_trace_id, "
904
+ f"{tombstone} AS _kiri_deleted_at, "
905
+ f"DATE '{d_iso}' AS _kiri_ingested_date "
906
+ f"FROM read_files('{v}', {fmt_opts}) AS src"
907
+ )
908
+
909
+
910
+ def build_merge_sql(
911
+ *,
912
+ target_fq: str,
913
+ source_select: str,
914
+ dedupe_key: list[str],
915
+ ) -> str:
916
+ """SQL MERGE: last-write-wins on _kiri_ingested_at per dedupe_key.
917
+
918
+ The source is de-duplicated on ``dedupe_key`` first (QUALIFY ROW_NUMBER,
919
+ keeping the latest ``_kiri_ingested_at``). Without it, a source batch that
920
+ repeats a key — e.g. the same business key landed twice in one fetch —
921
+ makes Delta reject the MERGE with
922
+ ``DELTA_MULTIPLE_SOURCE_ROW_MATCHING_TARGET_ROW_IN_MERGE`` (#268). This
923
+ mirrors the local runner's arrow-side dedup so both backends agree.
924
+ """
925
+ if not dedupe_key:
926
+ raise ValueError("dedupe_key is required for MERGE")
927
+ predicate = " AND ".join(
928
+ f"target.{_quote_ident(k)} = source.{_quote_ident(k)}" for k in dedupe_key
929
+ )
930
+ partition = ", ".join(_quote_ident(k) for k in dedupe_key)
931
+ deduped_source = (
932
+ f"SELECT * FROM ({source_select}) "
933
+ f"QUALIFY ROW_NUMBER() OVER ("
934
+ f"PARTITION BY {partition} ORDER BY _kiri_ingested_at DESC) = 1"
935
+ )
936
+ return (
937
+ f"MERGE INTO {target_fq} AS target "
938
+ f"USING ({deduped_source}) AS source "
939
+ f"ON {predicate} "
940
+ f"WHEN MATCHED AND source._kiri_ingested_at > target._kiri_ingested_at "
941
+ f" THEN UPDATE SET * "
942
+ f"WHEN NOT MATCHED THEN INSERT *"
943
+ )
944
+
945
+
946
+ def build_append_sql(*, target_fq: str, source_select: str) -> str:
947
+ """SQL INSERT when there is no dedupe_key."""
948
+ return f"INSERT INTO {target_fq} {source_select}"
949
+
950
+
951
+ def build_create_table_sql(*, target_fq: str, source_select: str) -> str:
952
+ """First-time CREATE TABLE AS SELECT (CTAS) — Delta by default on UC."""
953
+ return (
954
+ f"CREATE TABLE IF NOT EXISTS {target_fq} "
955
+ f"PARTITIONED BY (_kiri_ingested_date) "
956
+ f"AS {source_select} WHERE 1 = 0"
957
+ )
958
+
959
+
960
+ # ---- adapter --------------------------------------------------------------
961
+
962
+
963
+ class DatabricksAdapter(PlatformAdapter):
964
+ """Talks to Databricks via the Workspace SDK + Statement Execution API."""
965
+
966
+ name = "databricks"
967
+
968
+ def __init__(self, profile: dict[str, Any]) -> None:
969
+ super().__init__(profile)
970
+ self._client: Any | None = None
971
+ # Per-run cache for the landing-storage handle. Built lazily on
972
+ # first ``landing_storage()`` call so the adapter stays usable
973
+ # in profiles that don't declare an ADLS block (legacy tests).
974
+ self._landing_storage_cache: Any | None = None
975
+ # S8: simple per-adapter token-bucket gating Statement Execution
976
+ # API calls. Off by default; operators set ``statement_rps_limit``
977
+ # in profile to throttle concurrent applies that share a
978
+ # warehouse. Single-process — multi-replica deployments need
979
+ # per-warehouse coordination (out of scope for v1).
980
+ self._rps_limit: float | None = None
981
+ rps = profile.get("statement_rps_limit")
982
+ if rps is not None:
983
+ try:
984
+ rps_val = float(rps)
985
+ except (TypeError, ValueError) as e:
986
+ raise ValueError(f"statement_rps_limit must be a number; got {rps!r}") from e
987
+ if rps_val <= 0:
988
+ raise ValueError(f"statement_rps_limit must be > 0; got {rps_val}")
989
+ self._rps_limit = rps_val
990
+ self._last_call_at: float = 0.0
991
+
992
+ # N1: retry tunables. Operators with very fast warehouses tighten
993
+ # the backoff; ones on a noisy network ease it up. Defaults match
994
+ # the legacy hardcoded values so existing deploys see no change.
995
+ self._retry_attempts = int(profile.get("retry_attempts", 3))
996
+ if self._retry_attempts < 1:
997
+ raise ValueError(f"retry_attempts must be ≥ 1; got {self._retry_attempts}")
998
+ self._retry_backoff_base = float(profile.get("retry_backoff_base_seconds", 0.5))
999
+ if self._retry_backoff_base < 0:
1000
+ raise ValueError(
1001
+ f"retry_backoff_base_seconds must be ≥ 0; got {self._retry_backoff_base}"
1002
+ )
1003
+
1004
+ # ADR 0092: per-_execute audit is on by default. A legacy explicit
1005
+ # JSONL path is still honoured exactly; otherwise both projections
1006
+ # live under the project-threaded default audit dir.
1007
+ self._audit_paths = self._resolve_audit_paths(profile)
1008
+ self._audit_disabled_emitted = False
1009
+ self._audit_user: str | None = None
1010
+
1011
+ # ---- public API ------------------------------------------------------
1012
+
1013
+ def required_credentials(self) -> list[Credential]:
1014
+ return [
1015
+ Credential(
1016
+ env_var="DATABRICKS_HOST",
1017
+ description="Workspace URL, e.g. https://adb-123.azuredatabricks.net",
1018
+ ),
1019
+ Credential(
1020
+ env_var="DATABRICKS_TOKEN",
1021
+ description="PAT or OAuth token with SQL + Files API access",
1022
+ ),
1023
+ Credential(
1024
+ env_var="DATABRICKS_WAREHOUSE_ID",
1025
+ description="SQL warehouse ID used for statements and dbt",
1026
+ required=False,
1027
+ ),
1028
+ ]
1029
+
1030
+ def health_check(self) -> HealthStatus:
1031
+ try:
1032
+ result = self._execute("SELECT 1 AS ok")
1033
+ except Exception:
1034
+ return HealthStatus.UNHEALTHY
1035
+ return HealthStatus.HEALTHY if result is not None else HealthStatus.UNHEALTHY
1036
+
1037
+ def dialect(self) -> SqlDialect:
1038
+ """Return ``DeltaDialect`` for Unity Catalog Delta tables (#149).
1039
+
1040
+ ``DatabricksDwDialect`` (Photon ANSI SQL Warehouse) is still future
1041
+ work — when it lands the adapter will switch on a profile flag.
1042
+ """
1043
+ return DeltaDialect()
1044
+
1045
+ def landing_storage(self, vault: Any | None = None) -> Any:
1046
+ """Return the ADLS-backed landing storage (ADR 0046 phase 2).
1047
+
1048
+ Built once per adapter instance (= per ``kiri apply`` run), so
1049
+ the SDK client + credential lookup happen exactly once even
1050
+ when many tables share the same target. When the profile
1051
+ carries a ``${vault:...}`` reference for the credential, the
1052
+ caller MUST pass a ``vault`` :class:`kiri_vault.VaultClient` to
1053
+ resolve it; literal credential strings (test fixtures, dev)
1054
+ flow through unchanged.
1055
+
1056
+ Profile shape::
1057
+
1058
+ targets:
1059
+ prod:
1060
+ adapter: databricks
1061
+ landing_storage:
1062
+ scheme: adls
1063
+ account: kirimanalanding
1064
+ container: landing
1065
+ credential: ${vault:azure:landing-sas}
1066
+
1067
+ When ``landing_storage`` is absent from the profile, the
1068
+ adapter falls back to :class:`LocalLandingStorage` rooted at
1069
+ ``bronze_raw_root`` so non-Databricks development still works
1070
+ and existing tests stay green.
1071
+ """
1072
+ if self._landing_storage_cache is not None:
1073
+ return self._landing_storage_cache
1074
+
1075
+ block = self.profile.get("landing_storage")
1076
+ if not block:
1077
+ ingest_volume = self.profile.get("ingest_volume")
1078
+ if ingest_volume:
1079
+ # Preferred: write landing bytes straight into a UC
1080
+ # Volume the warehouse can ``read_files()`` from. The
1081
+ # source contract drives the fetch; the storage is the
1082
+ # adapter's choice (this branch is the Databricks one).
1083
+ from kiri_ingestion.storage import DatabricksVolumeLandingStorage
1084
+
1085
+ self._landing_storage_cache = DatabricksVolumeLandingStorage(
1086
+ workspace_client=self._workspace(),
1087
+ volume=str(ingest_volume),
1088
+ )
1089
+ return self._landing_storage_cache
1090
+
1091
+ # Back-compat: pre-ADR-0046 profiles drove ingest off the
1092
+ # control-plane FS. Kept for tests and adapters whose
1093
+ # warehouse can in fact read local paths (e.g. an in-pod
1094
+ # dev SQL endpoint); a real Databricks SQL warehouse
1095
+ # cannot read ``file://``, so set ``ingest_volume`` (or a
1096
+ # cloud ``landing_storage`` block) for production runs.
1097
+ from kiri_ingestion.storage import LocalLandingStorage
1098
+
1099
+ # Canonical key is ``bronze_root`` — the kiri.yml target, the
1100
+ # fetch stage and _resolve_profile_paths all use it. Keep
1101
+ # ``bronze_raw_root`` as a back-compat fallback.
1102
+ root_value = (
1103
+ self.profile.get("bronze_root")
1104
+ or self.profile.get("bronze_raw_root")
1105
+ or "./data/bronze"
1106
+ )
1107
+ root = Path(str(root_value)).resolve()
1108
+ self._landing_storage_cache = LocalLandingStorage(root)
1109
+ return self._landing_storage_cache
1110
+
1111
+ scheme = block.get("scheme", "adls")
1112
+ if scheme != "adls":
1113
+ raise ValueError(
1114
+ f"DatabricksAdapter.landing_storage: unsupported scheme {scheme!r}; "
1115
+ "only 'adls' is supported in phase 2."
1116
+ )
1117
+ account = block.get("account")
1118
+ container = block.get("container")
1119
+ credential = block.get("credential")
1120
+ if not (account and container and credential):
1121
+ raise ValueError(
1122
+ "DatabricksAdapter.landing_storage requires account, "
1123
+ "container, and credential in profile.landing_storage."
1124
+ )
1125
+
1126
+ resolved = self._resolve_credential(str(credential), vault)
1127
+
1128
+ from kiri_ingestion.storage import AdlsLandingStorage
1129
+
1130
+ self._landing_storage_cache = AdlsLandingStorage.from_credential(
1131
+ account=str(account),
1132
+ container=str(container),
1133
+ credential=resolved,
1134
+ )
1135
+ return self._landing_storage_cache
1136
+
1137
+ @staticmethod
1138
+ def _resolve_credential(value: str, vault: Any | None) -> str:
1139
+ """Resolve a profile credential string.
1140
+
1141
+ - ``${vault:<id>:<key>}`` requires a vault client; raises if
1142
+ one isn't passed.
1143
+ - Anything else is returned verbatim (literal SAS, env-var
1144
+ token, etc).
1145
+ """
1146
+ from kiri_vault.ref import VaultRef
1147
+
1148
+ parsed = VaultRef.parse(value)
1149
+ if parsed is None:
1150
+ return value
1151
+ if vault is None:
1152
+ raise RuntimeError(
1153
+ "DatabricksAdapter.landing_storage: credential is a "
1154
+ f"vault reference ({value!r}) but no vault client was "
1155
+ "provided. Pass project.vault_client() to landing_storage()."
1156
+ )
1157
+ return str(vault.get(parsed.secret_id, parsed.key))
1158
+
1159
+ def claim_target(self, manifest: ProjectManifest) -> None:
1160
+ """Idempotently claim the Unity Catalog for ``(project, env)``.
1161
+
1162
+ Mirrors the LocalDuckDB implementation of ADRs 0014 + 0017. Not
1163
+ currently exercised by CI (requires a live workspace); the code
1164
+ below codifies the SQL shape so the invariant cannot drift.
1165
+
1166
+ - Row-per-(project, env). Same project + different envs coexist;
1167
+ different projects in the same env collide.
1168
+ - Legacy 5-column manifest migrated in-place with ALTER TABLE
1169
+ (Delta supports ``ADD COLUMNS``).
1170
+ - Re-apply refreshes release_sha/tag/ts when the caller brought
1171
+ them (kiri release apply path); plain apply preserves existing.
1172
+ """
1173
+
1174
+ def esc(s: str | None) -> str:
1175
+ if s is None:
1176
+ return "NULL"
1177
+ return "'" + s.replace("'", "''") + "'"
1178
+
1179
+ catalog = self._catalog()
1180
+ fq_meta = f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}"
1181
+ fq_table = f"{fq_meta}.{_quote_ident(_MANIFEST_TABLE)}"
1182
+ self._execute(f"CREATE SCHEMA IF NOT EXISTS {fq_meta}")
1183
+ self._execute(
1184
+ f"CREATE TABLE IF NOT EXISTS {fq_table} ("
1185
+ " project_name STRING NOT NULL,"
1186
+ " project_version STRING NOT NULL,"
1187
+ " silver_technique STRING NOT NULL,"
1188
+ " first_write STRING NOT NULL,"
1189
+ " kirimana_version STRING NOT NULL,"
1190
+ " environment STRING NOT NULL,"
1191
+ " release_sha STRING,"
1192
+ " release_tag STRING,"
1193
+ " release_ts STRING"
1194
+ ") USING DELTA"
1195
+ )
1196
+
1197
+ # ADR 0017 migration: legacy 0014-shape tables get the missing
1198
+ # columns bolted on, existing rows backfilled env='dev'. Databricks
1199
+ # SQL has no ``ADD COLUMNS IF NOT EXISTS`` (parse error on a
1200
+ # second apply / on the full-schema table the CREATE above just
1201
+ # made), so introspect the live columns and add only the genuine
1202
+ # gaps — no-op when the table already carries them.
1203
+ existing_rows = self._execute(
1204
+ "SELECT column_name FROM system.information_schema.columns "
1205
+ "WHERE table_catalog = ? AND table_schema = ? AND table_name = ?",
1206
+ parameters=[catalog, _META_SCHEMA, _MANIFEST_TABLE],
1207
+ )
1208
+ existing = {str(r[0]).lower() for r in (existing_rows or [])}
1209
+ # ADR 0020 naming-lock columns join the 0017 migration set. The live
1210
+ # _kiri_project_manifest predates them, so they must be bolted on
1211
+ # before the INSERT/SELECT below references them. Databricks SQL has
1212
+ # no reliable ``ADD COLUMNS IF NOT EXISTS`` (parse error on the
1213
+ # full-schema table the CREATE above just made), so the same
1214
+ # information_schema introspection that guards the 0017 columns guards
1215
+ # these too — emitting only the genuine gaps, a no-op once present.
1216
+ migration_columns = [
1217
+ ("environment", "STRING"),
1218
+ ("release_sha", "STRING"),
1219
+ ("release_tag", "STRING"),
1220
+ ("release_ts", "STRING"),
1221
+ ("naming_locked_at", "STRING"),
1222
+ ("naming_snapshot", "STRING"),
1223
+ ]
1224
+ missing = [(c, t) for c, t in migration_columns if c.lower() not in existing]
1225
+ if missing:
1226
+ cols_sql = ", ".join(f"{c} {t}" for c, t in missing)
1227
+ self._execute(f"ALTER TABLE {fq_table} ADD COLUMNS ({cols_sql})")
1228
+ self._execute(f"UPDATE {fq_table} SET environment = 'dev' WHERE environment IS NULL")
1229
+
1230
+ # Slot lookup. Reads the locked naming_snapshot too (ADR 0020) so the
1231
+ # enforce/backfill decision below reuses this round-trip — no second
1232
+ # SELECT.
1233
+ slot_result = self._execute(
1234
+ f"SELECT project_name, project_version, silver_technique, "
1235
+ f"first_write, naming_snapshot FROM {fq_table} "
1236
+ f"WHERE project_name = {esc(manifest.project_name)} "
1237
+ f"AND environment = {esc(manifest.environment)}"
1238
+ )
1239
+ slot_rows = list(slot_result) if slot_result is not None else []
1240
+
1241
+ # Cross-project collision in the same env.
1242
+ other_result = self._execute(
1243
+ f"SELECT project_name, project_version, silver_technique, "
1244
+ f"first_write FROM {fq_table} "
1245
+ f"WHERE environment = {esc(manifest.environment)} "
1246
+ f"AND project_name <> {esc(manifest.project_name)}"
1247
+ )
1248
+ other_rows = list(other_result) if other_result is not None else []
1249
+ if other_rows:
1250
+ other_name, other_ver, other_tech, other_first = other_rows[0]
1251
+ raise ProjectTargetCollisionError(
1252
+ f"Environment {manifest.environment!r} in catalog "
1253
+ f"{catalog!r} is owned by project {other_name!r} "
1254
+ f"(version {other_ver}, silver technique {other_tech!r}, "
1255
+ f"first write {other_first}). Cannot apply from project "
1256
+ f"{manifest.project_name!r}. Either pick a different "
1257
+ "target/environment in kiri.yml or coordinate with the "
1258
+ "owning project."
1259
+ )
1260
+
1261
+ if not slot_rows:
1262
+ self._execute(
1263
+ f"INSERT INTO {fq_table} "
1264
+ f"(project_name, project_version, silver_technique, "
1265
+ f"first_write, kirimana_version, environment, "
1266
+ f"release_sha, release_tag, release_ts, "
1267
+ f"naming_locked_at, naming_snapshot) "
1268
+ f"VALUES ("
1269
+ f"{esc(manifest.project_name)}, "
1270
+ f"{esc(manifest.project_version)}, "
1271
+ f"{esc(manifest.silver_technique)}, "
1272
+ f"{esc(manifest.first_write)}, "
1273
+ f"{esc(manifest.kirimana_version)}, "
1274
+ f"{esc(manifest.environment)}, "
1275
+ f"{esc(manifest.release_sha)}, "
1276
+ f"{esc(manifest.release_tag)}, "
1277
+ f"{esc(manifest.release_ts)}, "
1278
+ f"{esc(manifest.naming_locked_at)}, "
1279
+ f"{esc(manifest.naming_snapshot)})"
1280
+ )
1281
+ return
1282
+
1283
+ if len(slot_rows) > 1:
1284
+ raise ProjectTargetCollisionError(
1285
+ f"{fq_table} contains {len(slot_rows)} rows for "
1286
+ f"(project={manifest.project_name!r}, "
1287
+ f"env={manifest.environment!r}) — catalog metadata is "
1288
+ "corrupted. Inspect and resolve."
1289
+ )
1290
+
1291
+ # ADR 0020 naming-lock enforcement (same-slot re-apply). The locked
1292
+ # snapshot was read in the slot SELECT above (column 5).
1293
+ stored_snapshot = slot_rows[0][4]
1294
+ incoming_snapshot = manifest.naming_snapshot
1295
+ if ProjectManifest.naming_snapshot_diverges(stored_snapshot, incoming_snapshot):
1296
+ raise NamingLockError(
1297
+ f"naming block for project {manifest.project_name!r} "
1298
+ f"(environment {manifest.environment!r}) diverges from the "
1299
+ "locked naming snapshot recorded on first write.\n"
1300
+ f" locked: {stored_snapshot}\n"
1301
+ f" incoming: {incoming_snapshot}\n"
1302
+ "Naming conventions are locked after the first apply "
1303
+ "(ADR 0020). Restore the locked naming in kiri.yml, or run "
1304
+ "`kiri naming migrate` (future) to rewrite the warehouse "
1305
+ "objects and re-lock. Refusing to apply against live data "
1306
+ "with a changed naming scheme."
1307
+ )
1308
+ if stored_snapshot is None and incoming_snapshot is not None:
1309
+ # Pre-upgrade manifest row written before this feature: no snapshot
1310
+ # was ever recorded. Backfill it (graceful upgrade, never a
1311
+ # failure) so the lock takes effect from now on. The
1312
+ # `naming_snapshot IS NULL` predicate makes this a compare-and-set:
1313
+ # if a concurrent apply locked a snapshot between the slot SELECT
1314
+ # and here, this UPDATE is a no-op rather than clobbering the lock.
1315
+ self._execute(
1316
+ f"UPDATE {fq_table} SET "
1317
+ f"naming_locked_at = {esc(manifest.naming_locked_at)}, "
1318
+ f"naming_snapshot = {esc(incoming_snapshot)} "
1319
+ f"WHERE project_name = {esc(manifest.project_name)} "
1320
+ f"AND environment = {esc(manifest.environment)} "
1321
+ f"AND naming_snapshot IS NULL"
1322
+ )
1323
+
1324
+ # Same slot — idempotent reclaim. Refresh release fields if the
1325
+ # caller supplied them (kiri release apply post-success).
1326
+ if (
1327
+ manifest.release_sha is not None
1328
+ or manifest.release_tag is not None
1329
+ or manifest.release_ts is not None
1330
+ ):
1331
+ self._execute(
1332
+ f"UPDATE {fq_table} SET "
1333
+ f"release_sha = {esc(manifest.release_sha)}, "
1334
+ f"release_tag = {esc(manifest.release_tag)}, "
1335
+ f"release_ts = {esc(manifest.release_ts)}, "
1336
+ f"project_version = {esc(manifest.project_version)}, "
1337
+ f"kirimana_version = {esc(manifest.kirimana_version)} "
1338
+ f"WHERE project_name = {esc(manifest.project_name)} "
1339
+ f"AND environment = {esc(manifest.environment)}"
1340
+ )
1341
+
1342
+ def read_manifest(self, project_name: str, environment: str) -> ProjectManifest | None:
1343
+ """Return the manifest row for (project, env), or None if absent.
1344
+
1345
+ Delta mirror of the LocalDuckDB path. Not CI-tested (requires a
1346
+ live workspace); catches every exception and maps to None so
1347
+ `kiri release status` on a workspace without the schema/table
1348
+ reports "not applied yet" rather than blowing up.
1349
+ """
1350
+
1351
+ def esc(s: str) -> str:
1352
+ return "'" + s.replace("'", "''") + "'"
1353
+
1354
+ catalog = self._catalog()
1355
+ fq_table = (
1356
+ f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}.{_quote_ident(_MANIFEST_TABLE)}"
1357
+ )
1358
+ try:
1359
+ result = self._execute(
1360
+ f"SELECT project_name, project_version, silver_technique, "
1361
+ f"first_write, kirimana_version, environment, "
1362
+ f"release_sha, release_tag, release_ts, "
1363
+ f"naming_locked_at, naming_snapshot "
1364
+ f"FROM {fq_table} "
1365
+ f"WHERE project_name = {esc(project_name)} "
1366
+ f"AND environment = {esc(environment)}"
1367
+ )
1368
+ except Exception:
1369
+ return None
1370
+ rows = list(result) if result is not None else []
1371
+ if not rows:
1372
+ return None
1373
+ row = rows[0]
1374
+ return ProjectManifest(
1375
+ project_name=row[0],
1376
+ project_version=row[1],
1377
+ silver_technique=row[2],
1378
+ first_write=row[3],
1379
+ kirimana_version=row[4],
1380
+ environment=row[5],
1381
+ release_sha=row[6],
1382
+ release_tag=row[7],
1383
+ release_ts=row[8],
1384
+ naming_locked_at=row[9],
1385
+ naming_snapshot=row[10],
1386
+ )
1387
+
1388
+ def log_apply(self, entry: ApplyLogEntry) -> None:
1389
+ """Upsert one row of ``_kiri_meta._kiri_apply_log`` (ADR 0017).
1390
+
1391
+ Delta Lake shape mirrors the LocalDuckDB implementation. Not
1392
+ CI-tested (requires a live workspace); SQL form codifies the
1393
+ invariant so it cannot drift.
1394
+ """
1395
+
1396
+ def esc(s: str | None) -> str:
1397
+ if s is None:
1398
+ return "NULL"
1399
+ return "'" + s.replace("'", "''") + "'"
1400
+
1401
+ catalog = self._catalog()
1402
+ fq_meta = f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}"
1403
+ fq_log = f"{fq_meta}.{_quote_ident(_APPLY_LOG_TABLE)}"
1404
+ self._execute(f"CREATE SCHEMA IF NOT EXISTS {fq_meta}")
1405
+ self._execute(
1406
+ f"CREATE TABLE IF NOT EXISTS {fq_log} ("
1407
+ " apply_id STRING NOT NULL,"
1408
+ " project_name STRING NOT NULL,"
1409
+ " environment STRING NOT NULL,"
1410
+ " release_sha STRING,"
1411
+ " release_tag STRING,"
1412
+ " release_ts STRING,"
1413
+ " kirimana_version STRING NOT NULL,"
1414
+ " started_at STRING NOT NULL,"
1415
+ " ended_at STRING,"
1416
+ " status STRING NOT NULL,"
1417
+ " error_message STRING"
1418
+ ") USING DELTA"
1419
+ )
1420
+ existing_result = self._execute(
1421
+ f"SELECT apply_id FROM {fq_log} WHERE apply_id = {esc(entry.apply_id)}"
1422
+ )
1423
+ existing_rows = list(existing_result) if existing_result is not None else []
1424
+ if not existing_rows:
1425
+ self._execute(
1426
+ f"INSERT INTO {fq_log} "
1427
+ f"(apply_id, project_name, environment, release_sha, "
1428
+ f" release_tag, release_ts, kirimana_version, started_at, "
1429
+ f" ended_at, status, error_message) VALUES ("
1430
+ f"{esc(entry.apply_id)}, {esc(entry.project_name)}, "
1431
+ f"{esc(entry.environment)}, {esc(entry.release_sha)}, "
1432
+ f"{esc(entry.release_tag)}, {esc(entry.release_ts)}, "
1433
+ f"{esc(entry.kirimana_version)}, {esc(entry.started_at)}, "
1434
+ f"{esc(entry.ended_at)}, {esc(entry.status)}, "
1435
+ f"{esc(entry.error_message)})"
1436
+ )
1437
+ else:
1438
+ self._execute(
1439
+ f"UPDATE {fq_log} SET "
1440
+ f"release_sha = {esc(entry.release_sha)}, "
1441
+ f"release_tag = {esc(entry.release_tag)}, "
1442
+ f"release_ts = {esc(entry.release_ts)}, "
1443
+ f"ended_at = {esc(entry.ended_at)}, "
1444
+ f"status = {esc(entry.status)}, "
1445
+ f"error_message = {esc(entry.error_message)} "
1446
+ f"WHERE apply_id = {esc(entry.apply_id)}"
1447
+ )
1448
+
1449
+ def read_watermark(self, asset_urn: str, environment: str) -> AssetWatermark | None:
1450
+ """Return the watermark row for (asset_urn, env), or None if absent.
1451
+
1452
+ Delta mirror of the LocalDuckDB path (ADR 0080 §2). Not CI-tested
1453
+ (requires a live workspace). Only the "table or schema does not exist"
1454
+ failure maps to None ("no cursor yet" → first run); EVERY other failure
1455
+ (permission / transport / malformed row) is wrapped in
1456
+ :class:`WatermarkStoreError` and raised. Flattening a real read error to
1457
+ None would silently reprocess the full history (P0 data-correctness
1458
+ hazard). The rendered SQL is pinned by a golden-SQL unit test.
1459
+ """
1460
+
1461
+ def esc(s: str) -> str:
1462
+ return "'" + s.replace("'", "''") + "'"
1463
+
1464
+ catalog = self._catalog()
1465
+ fq_table = (
1466
+ f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}."
1467
+ f"{_quote_ident(_ASSET_WATERMARKS_TABLE)}"
1468
+ )
1469
+ try:
1470
+ result = self._execute(
1471
+ f"SELECT asset_urn, target_env, grain, last_processed_value, "
1472
+ f"last_run_id, updated_at "
1473
+ f"FROM {fq_table} "
1474
+ f"WHERE asset_urn = {esc(asset_urn)} "
1475
+ f"AND target_env = {esc(environment)}"
1476
+ )
1477
+ except Exception as exc:
1478
+ if _is_table_or_schema_not_found(exc):
1479
+ # Legitimate "no watermark store yet" → first run / no cursor.
1480
+ return None
1481
+ # Any other failure is NOT absence — fail closed so a transient read
1482
+ # error is never mistaken for an empty cursor (full reprocess).
1483
+ raise WatermarkStoreError(
1484
+ f"read_watermark failed for asset_urn={asset_urn!r} env={environment!r}: {exc}"
1485
+ ) from exc
1486
+ rows = list(result) if result is not None else []
1487
+ if not rows:
1488
+ return None
1489
+ row = rows[0]
1490
+ return AssetWatermark(
1491
+ asset_urn=row[0],
1492
+ target_env=row[1],
1493
+ grain=row[2],
1494
+ last_processed_value=row[3],
1495
+ last_run_id=row[4],
1496
+ updated_at=row[5],
1497
+ )
1498
+
1499
+ def advance_watermark(
1500
+ self,
1501
+ asset_urn: str,
1502
+ environment: str,
1503
+ *,
1504
+ grain: str,
1505
+ last_processed_value: str,
1506
+ last_run_id: str | None,
1507
+ ) -> None:
1508
+ """Upsert one row of ``_kiri_meta._kiri_asset_watermarks`` (ADR 0080 §2).
1509
+
1510
+ Delta Lake shape mirroring the LocalDuckDB implementation and the
1511
+ ``_kiri_release_state`` sink: CREATE SCHEMA / TABLE IF NOT EXISTS, then
1512
+ the established atomic upsert — an idempotent ``UPDATE ... WHERE <key>``
1513
+ (0 rows when absent) followed by a guarded
1514
+ ``INSERT ... SELECT <literals> WHERE NOT EXISTS (SELECT 1 ...)`` (no-op
1515
+ when present). Never MERGE, never a Python-side read-then-write branch.
1516
+ Idempotent on ``(asset_urn, target_env)`` — one row per slot.
1517
+
1518
+ Delta has no enforced PRIMARY KEY, so the guarded-insert idiom is itself
1519
+ the backstop against a duplicate row — exactly as the other ``_kiri_meta``
1520
+ Delta tables (``_kiri_release_state`` / ``_kiri_active_contract_versions``)
1521
+ do. Not CI-tested against a live cluster; the rendered SQL form is pinned
1522
+ by a golden-SQL unit test so it cannot drift.
1523
+ """
1524
+
1525
+ def esc(s: str | None) -> str:
1526
+ if s is None:
1527
+ return "NULL"
1528
+ return "'" + s.replace("'", "''") + "'"
1529
+
1530
+ from datetime import datetime
1531
+
1532
+ updated_at = datetime.now(UTC).isoformat()
1533
+ catalog = self._catalog()
1534
+ fq_meta = f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}"
1535
+ fq_table = f"{fq_meta}.{_quote_ident(_ASSET_WATERMARKS_TABLE)}"
1536
+ self._execute(f"CREATE SCHEMA IF NOT EXISTS {fq_meta}")
1537
+ self._execute(
1538
+ f"CREATE TABLE IF NOT EXISTS {fq_table} ("
1539
+ " asset_urn STRING NOT NULL,"
1540
+ " target_env STRING NOT NULL,"
1541
+ " grain STRING NOT NULL,"
1542
+ " last_processed_value STRING,"
1543
+ " last_run_id STRING,"
1544
+ " updated_at STRING NOT NULL"
1545
+ ") USING DELTA"
1546
+ )
1547
+ wm_key = f"asset_urn = {esc(asset_urn)} AND target_env = {esc(environment)}"
1548
+ # Atomic: idempotent UPDATE (0 rows when absent) then a guarded INSERT
1549
+ # (no-op when present) — the duplicate-row backstop on Delta (no PK).
1550
+ self._execute(
1551
+ f"UPDATE {fq_table} SET "
1552
+ f"grain = {esc(grain)}, "
1553
+ f"last_processed_value = {esc(last_processed_value)}, "
1554
+ f"last_run_id = {esc(last_run_id)}, "
1555
+ f"updated_at = {esc(updated_at)} "
1556
+ f"WHERE {wm_key}"
1557
+ )
1558
+ self._execute(
1559
+ f"INSERT INTO {fq_table} "
1560
+ f"(asset_urn, target_env, grain, last_processed_value, "
1561
+ f" last_run_id, updated_at) "
1562
+ f"SELECT {esc(asset_urn)}, {esc(environment)}, {esc(grain)}, "
1563
+ f"{esc(last_processed_value)}, {esc(last_run_id)}, {esc(updated_at)} "
1564
+ f"WHERE NOT EXISTS (SELECT 1 FROM {fq_table} WHERE {wm_key})"
1565
+ )
1566
+
1567
+ def mirror_release_manifest(self, entry: ReleaseManifestEntry) -> None:
1568
+ """Mirror one apply's release manifest into ``_kiri_meta`` (ADR 0041).
1569
+
1570
+ Delta Lake shape, validated live against ``kirimana_mvp``. CREATE
1571
+ SCHEMA/TABLE IF NOT EXISTS, then an atomic conditional-insert upsert
1572
+ (never MERGE, never a Python-side read-then-write branch): a guarded
1573
+ ``UPDATE ... WHERE <key>`` followed by an
1574
+ ``INSERT ... SELECT <literals> WHERE NOT EXISTS (SELECT 1 ...)``. The
1575
+ FROM-less ``SELECT`` + ``WHERE NOT EXISTS`` was validated live on Delta
1576
+ and is byte-for-byte the same logical pattern the DuckDB backend uses,
1577
+ so the two backends converge. Not CI-tested against a live cluster; the
1578
+ rendered SQL form is pinned by a golden-SQL unit test so it cannot
1579
+ drift.
1580
+
1581
+ - ``_kiri_release_state``: keyed on ``environment`` (UPDATE-then-guarded
1582
+ INSERT).
1583
+ - ``_kiri_active_contract_versions``: keyed on ``(environment, urn)``
1584
+ (UPDATE-then-guarded INSERT per changed contract).
1585
+ - ``_kiri_promotion_history``: append-only, single guarded INSERT on
1586
+ ``audit_id`` so re-applying the same apply does not duplicate a row.
1587
+
1588
+ Self-fail-soft: the whole body is wrapped in ``try/except`` and logs a
1589
+ best-effort warning on any error — this governance sink must never raise
1590
+ out of the adapter and block a successful apply. The SQL itself is
1591
+ idempotent so a repeated call against the same audit_id is a no-op for
1592
+ history and an UPDATE for state/versions.
1593
+ """
1594
+ try:
1595
+ self._mirror_release_manifest_unguarded(entry)
1596
+ except Exception as exc: # governance sink must never raise
1597
+ _logger.warning(
1598
+ "mirror_release_manifest failed for audit_id=%s (governance sink, best-effort): %s",
1599
+ entry.audit_id,
1600
+ exc,
1601
+ )
1602
+
1603
+ def _mirror_release_manifest_unguarded(self, entry: ReleaseManifestEntry) -> None:
1604
+ def esc(s: str | None) -> str:
1605
+ if s is None:
1606
+ return "NULL"
1607
+ return "'" + s.replace("'", "''") + "'"
1608
+
1609
+ def boollit(b: bool) -> str:
1610
+ return "TRUE" if b else "FALSE"
1611
+
1612
+ catalog = self._catalog()
1613
+ fq_meta = f"{_quote_ident(catalog)}.{_quote_ident(_META_SCHEMA)}"
1614
+ fq_state = f"{fq_meta}.{_quote_ident(_RELEASE_STATE_TABLE)}"
1615
+ fq_versions = f"{fq_meta}.{_quote_ident(_ACTIVE_CONTRACT_VERSIONS_TABLE)}"
1616
+ fq_history = f"{fq_meta}.{_quote_ident(_PROMOTION_HISTORY_TABLE)}"
1617
+
1618
+ self._execute(f"CREATE SCHEMA IF NOT EXISTS {fq_meta}")
1619
+ self._execute(
1620
+ f"CREATE TABLE IF NOT EXISTS {fq_state} ("
1621
+ " environment STRING NOT NULL,"
1622
+ " release_sha STRING NOT NULL,"
1623
+ " ordinal INT,"
1624
+ " last_applied_at STRING NOT NULL,"
1625
+ " last_applied_by STRING NOT NULL,"
1626
+ " last_audit_id STRING NOT NULL,"
1627
+ " last_apply_status STRING NOT NULL,"
1628
+ " updated_at STRING"
1629
+ ") USING DELTA"
1630
+ )
1631
+ self._execute(
1632
+ f"CREATE TABLE IF NOT EXISTS {fq_versions} ("
1633
+ " environment STRING NOT NULL,"
1634
+ " contract_urn STRING NOT NULL,"
1635
+ " contract_version STRING NOT NULL,"
1636
+ " applied_at STRING NOT NULL,"
1637
+ " applied_by STRING NOT NULL,"
1638
+ " audit_id STRING NOT NULL,"
1639
+ " is_breaking BOOLEAN,"
1640
+ " is_hotfix BOOLEAN,"
1641
+ " previous_version STRING"
1642
+ ") USING DELTA"
1643
+ )
1644
+ self._execute(
1645
+ f"CREATE TABLE IF NOT EXISTS {fq_history} ("
1646
+ " audit_id STRING NOT NULL,"
1647
+ " environment STRING NOT NULL,"
1648
+ " release_sha STRING NOT NULL,"
1649
+ " initiated_at STRING NOT NULL,"
1650
+ " completed_at STRING,"
1651
+ " initiated_by STRING NOT NULL,"
1652
+ " status STRING NOT NULL,"
1653
+ " changed_contracts STRING,"
1654
+ " breaking_ack BOOLEAN,"
1655
+ " hotfix BOOLEAN"
1656
+ ") USING DELTA"
1657
+ )
1658
+
1659
+ ordinal_lit = "NULL" if entry.ordinal is None else str(entry.ordinal)
1660
+
1661
+ # release_state — one row per environment. Atomic: idempotent UPDATE
1662
+ # (0 rows when absent) then a guarded INSERT (no-op when present).
1663
+ state_key = f"environment = {esc(entry.environment)}"
1664
+ self._execute(
1665
+ f"UPDATE {fq_state} SET "
1666
+ f"release_sha = {esc(entry.release_sha)}, "
1667
+ f"ordinal = {ordinal_lit}, "
1668
+ f"last_applied_at = {esc(entry.applied_at)}, "
1669
+ f"last_applied_by = {esc(entry.applied_by)}, "
1670
+ f"last_audit_id = {esc(entry.audit_id)}, "
1671
+ f"last_apply_status = {esc(entry.status)}, "
1672
+ f"updated_at = {esc(entry.applied_at)} "
1673
+ f"WHERE {state_key}"
1674
+ )
1675
+ self._execute(
1676
+ f"INSERT INTO {fq_state} "
1677
+ f"(environment, release_sha, ordinal, last_applied_at, "
1678
+ f" last_applied_by, last_audit_id, last_apply_status, updated_at) "
1679
+ f"SELECT {esc(entry.environment)}, {esc(entry.release_sha)}, "
1680
+ f"{ordinal_lit}, {esc(entry.applied_at)}, {esc(entry.applied_by)}, "
1681
+ f"{esc(entry.audit_id)}, {esc(entry.status)}, {esc(entry.applied_at)} "
1682
+ f"WHERE NOT EXISTS (SELECT 1 FROM {fq_state} WHERE {state_key})"
1683
+ )
1684
+
1685
+ # active_contract_versions — one row per (environment, contract_urn).
1686
+ # Same atomic UPDATE-then-guarded-INSERT pattern.
1687
+ for change in entry.changed_contracts:
1688
+ ver_key = f"environment = {esc(entry.environment)} AND contract_urn = {esc(change.urn)}"
1689
+ self._execute(
1690
+ f"UPDATE {fq_versions} SET "
1691
+ f"contract_version = {esc(change.to_version)}, "
1692
+ f"applied_at = {esc(entry.applied_at)}, "
1693
+ f"applied_by = {esc(entry.applied_by)}, "
1694
+ f"audit_id = {esc(entry.audit_id)}, "
1695
+ f"is_breaking = {boollit(change.breaking)}, "
1696
+ f"is_hotfix = {boollit(change.hotfix)}, "
1697
+ f"previous_version = {esc(change.from_version)} "
1698
+ f"WHERE {ver_key}"
1699
+ )
1700
+ self._execute(
1701
+ f"INSERT INTO {fq_versions} "
1702
+ f"(environment, contract_urn, contract_version, applied_at, "
1703
+ f" applied_by, audit_id, is_breaking, is_hotfix, previous_version) "
1704
+ f"SELECT {esc(entry.environment)}, {esc(change.urn)}, "
1705
+ f"{esc(change.to_version)}, {esc(entry.applied_at)}, "
1706
+ f"{esc(entry.applied_by)}, {esc(entry.audit_id)}, "
1707
+ f"{boollit(change.breaking)}, {boollit(change.hotfix)}, "
1708
+ f"{esc(change.from_version)} "
1709
+ f"WHERE NOT EXISTS (SELECT 1 FROM {fq_versions} WHERE {ver_key})"
1710
+ )
1711
+
1712
+ # promotion_history — append-only, single guarded INSERT on audit_id.
1713
+ changed_json = json.dumps(
1714
+ [
1715
+ {
1716
+ "urn": c.urn,
1717
+ "from_version": c.from_version,
1718
+ "to_version": c.to_version,
1719
+ "breaking": c.breaking,
1720
+ "hotfix": c.hotfix,
1721
+ }
1722
+ for c in entry.changed_contracts
1723
+ ]
1724
+ )
1725
+ breaking_ack = any(c.breaking for c in entry.changed_contracts)
1726
+ hotfix = any(c.hotfix for c in entry.changed_contracts)
1727
+ self._execute(
1728
+ f"INSERT INTO {fq_history} "
1729
+ f"(audit_id, environment, release_sha, initiated_at, "
1730
+ f" completed_at, initiated_by, status, changed_contracts, "
1731
+ f" breaking_ack, hotfix) "
1732
+ f"SELECT {esc(entry.audit_id)}, {esc(entry.environment)}, "
1733
+ f"{esc(entry.release_sha)}, {esc(entry.applied_at)}, "
1734
+ f"{esc(entry.applied_at)}, {esc(entry.applied_by)}, "
1735
+ f"{esc(entry.status)}, {esc(changed_json)}, "
1736
+ f"{boollit(breaking_ack)}, {boollit(hotfix)} "
1737
+ f"WHERE NOT EXISTS (SELECT 1 FROM {fq_history} "
1738
+ f"WHERE audit_id = {esc(entry.audit_id)})"
1739
+ )
1740
+
1741
+ def resolve_storage(
1742
+ self,
1743
+ binding: StorageBinding,
1744
+ *,
1745
+ live: bool = False,
1746
+ provision: bool = False,
1747
+ ) -> StorageResolution:
1748
+ """Map a per-layer storage binding to live Unity Catalog (ADR 0132 §3).
1749
+
1750
+ Read+validate (when ``live``) then optional operator-gated provision.
1751
+ Provisioning DDL runs **only** when ``provision is True`` AND this adapter
1752
+ ``supports("unity-catalog")`` — a bare ``live=True`` read+validates but
1753
+ never mutates. Fully fail-soft — every UC interaction is wrapped so auth /
1754
+ permission / not-found errors land in ``issues`` and the method never
1755
+ raises out (the apply stage treats the result as advisory; the hard gate
1756
+ is the author-time ``validate_project``).
1757
+ """
1758
+ do_provision = provision and self.supports("unity-catalog")
1759
+ try:
1760
+ return self._resolve_storage_unguarded(binding, live=live, provision=do_provision)
1761
+ except Exception as exc: # storage resolve must never raise out
1762
+ _logger.warning(
1763
+ "resolve_storage failed for external_location=%s (advisory, best-effort): %s",
1764
+ binding.external_location,
1765
+ exc,
1766
+ )
1767
+ return StorageResolution(
1768
+ supported=True,
1769
+ dry_run=not do_provision,
1770
+ issues=[
1771
+ f"resolve_storage failed for external location "
1772
+ f"{binding.external_location!r} (error category: "
1773
+ f"{type(exc).__name__})"
1774
+ ],
1775
+ )
1776
+
1777
+ def _resolve_storage_unguarded(
1778
+ self,
1779
+ binding: StorageBinding,
1780
+ *,
1781
+ live: bool,
1782
+ provision: bool,
1783
+ ) -> StorageResolution:
1784
+ from kiri_core.storage_paths import (
1785
+ is_strict_subpath,
1786
+ is_vault_ref,
1787
+ parse_location,
1788
+ )
1789
+
1790
+ issues: list[str] = []
1791
+ provisioned: list[str] = []
1792
+
1793
+ # ---- Read: DESCRIBE EXTERNAL LOCATION `<name>` -----------------------
1794
+ # Vault-ref external locations are opaque (resolved elsewhere); we cannot
1795
+ # DESCRIBE a secret, so skip the live read and record nothing — author-
1796
+ # time §4.4 covers resolvability.
1797
+ url: str | None = None
1798
+ credential_name: str | None = None
1799
+ location_exists = False
1800
+ if live and not is_vault_ref(binding.external_location):
1801
+ rows = self._execute(_render_describe_external_location(binding.external_location))
1802
+ # DESCRIBE EXTERNAL LOCATION returns columns
1803
+ # (name, url, credential_name, owner, ...). Read positionally: the
1804
+ # first three columns are stable across runtimes we target.
1805
+ if rows:
1806
+ location_exists = True
1807
+ first = rows[0]
1808
+ url = str(first[1]) if len(first) > 1 and first[1] is not None else None
1809
+ credential_name = str(first[2]) if len(first) > 2 and first[2] is not None else None
1810
+ else:
1811
+ # ADR-006 footgun: the external location named by the binding is
1812
+ # absent live. Record it (do not raise) — this is exactly the
1813
+ # missing-location case caught at apply time.
1814
+ issues.append(
1815
+ f"external location {binding.external_location!r} does not "
1816
+ f"exist (DESCRIBE EXTERNAL LOCATION returned no rows)"
1817
+ )
1818
+
1819
+ # ---- Validate: managed/schema roots are sub-paths of url -------------
1820
+ if url is not None:
1821
+ ext = parse_location(url)
1822
+ if ext is None:
1823
+ issues.append(
1824
+ f"external location url {url!r} is not a parseable scheme://authority/path URL"
1825
+ )
1826
+ else:
1827
+ candidates: list[tuple[str, str]] = [("managed_location", binding.managed_location)]
1828
+ candidates.extend(
1829
+ (f"schema_locations.{schema}", value)
1830
+ for schema, value in binding.schema_locations.items()
1831
+ )
1832
+ for field_path, value in candidates:
1833
+ if is_vault_ref(value):
1834
+ continue
1835
+ loc = parse_location(value)
1836
+ if loc is None:
1837
+ issues.append(
1838
+ f"{field_path} {value!r} is not a parseable scheme://authority/path URL"
1839
+ )
1840
+ elif not is_strict_subpath(loc, ext):
1841
+ issues.append(
1842
+ f"{field_path} {value!r} is not a strict sub-path of "
1843
+ f"the external location url {url!r}"
1844
+ )
1845
+ if not credential_name:
1846
+ issues.append(
1847
+ f"external location {binding.external_location!r} has no storage credential"
1848
+ )
1849
+
1850
+ # ---- Provision (opt-in): caller-gated provision flag -----------------
1851
+ # The caller (CLI) has already computed the full four-part operator gate
1852
+ # (binding.provision AND KIRIMANA_STORAGE_PROVISION_LIVE AND
1853
+ # supports(unity-catalog)); resolve_storage re-checks supports() for
1854
+ # defense-in-depth before passing ``provision`` here.
1855
+ do_provision = provision
1856
+ if do_provision:
1857
+ catalog = self._catalog()
1858
+ # External location: only create when it is genuinely absent and we
1859
+ # have an explicit root URL plus a credential to back it. The common
1860
+ # case is a pre-existing location (creation is the rare opt-in). The
1861
+ # external_location field is a NAME, never a URL — we render the
1862
+ # CREATE from the distinct ``external_location_url``. When the URL is
1863
+ # missing we record an issue and skip the CREATE rather than guess.
1864
+ if not location_exists and not is_vault_ref(binding.external_location):
1865
+ if binding.external_location_url and binding.storage_credential:
1866
+ ddl = _render_create_external_location(
1867
+ binding.external_location,
1868
+ binding.external_location_url,
1869
+ binding.storage_credential,
1870
+ )
1871
+ self._execute(ddl)
1872
+ provisioned.append(ddl)
1873
+ elif not binding.external_location_url:
1874
+ issues.append(
1875
+ f"external location {binding.external_location!r} absent "
1876
+ "and no external_location_url to create it"
1877
+ )
1878
+ else:
1879
+ issues.append(
1880
+ f"external location {binding.external_location!r} absent "
1881
+ "and no storage_credential to create it"
1882
+ )
1883
+ # Per-schema MANAGED LOCATION — idempotent CREATE SCHEMA.
1884
+ for schema, location in binding.schema_locations.items():
1885
+ if is_vault_ref(location):
1886
+ continue
1887
+ ddl = _render_create_schema_managed_location(catalog, schema, location)
1888
+ self._execute(ddl)
1889
+ provisioned.append(ddl)
1890
+
1891
+ return StorageResolution(
1892
+ supported=True,
1893
+ external_location_url=url,
1894
+ credential_name=credential_name,
1895
+ validated=(live and not is_vault_ref(binding.external_location)) and not issues,
1896
+ issues=issues,
1897
+ provisioned=provisioned,
1898
+ dry_run=not do_provision,
1899
+ )
1900
+
1901
+ def create_schema(self, schema_name: str) -> RunResult:
1902
+ start = time.monotonic()
1903
+ catalog = self._catalog()
1904
+ try:
1905
+ self._execute(
1906
+ f"CREATE SCHEMA IF NOT EXISTS {_quote_ident(catalog)}.{_quote_ident(schema_name)}"
1907
+ )
1908
+ except Exception as e:
1909
+ return RunResult(
1910
+ status=RunStatus.FAILED,
1911
+ message=f"create_schema failed: {e}",
1912
+ )
1913
+ return RunResult(
1914
+ status=RunStatus.SUCCESS,
1915
+ message=f"schema {catalog}.{schema_name} ready",
1916
+ duration_ms=int((time.monotonic() - start) * 1000),
1917
+ )
1918
+
1919
+ def ingest_bronze(self, source: Source, plan: IngestionPlan) -> RunResult:
1920
+ """Dispatch on the table's ingestion.mode, mirroring LocalDuckDB."""
1921
+ ingestion = plan.effective_ingestion()
1922
+ mode = ingestion.mode if ingestion else IngestionMode.FIXTURE_FILE
1923
+ # REST_API / DATABASE / SOAP fetchers drop files into _landing/
1924
+ # just like an external system would for LANDING_ZONE — from here
1925
+ # all four are ingested identically (mirrors LocalDuckDB).
1926
+ if mode in {
1927
+ IngestionMode.LANDING_ZONE,
1928
+ IngestionMode.REST_API,
1929
+ IngestionMode.DATABASE,
1930
+ IngestionMode.SOAP,
1931
+ }:
1932
+ return self._ingest_landing_zone(source, plan)
1933
+ return self._ingest_fixture(source, plan)
1934
+
1935
+ # ---- fixture mode (legacy demo path) ---------------------------------
1936
+
1937
+ def _ingest_fixture(self, source: Source, plan: IngestionPlan) -> RunResult:
1938
+ fixture = self._resolve_fixture(source, plan)
1939
+ if fixture is None:
1940
+ return RunResult(
1941
+ status=RunStatus.FAILED,
1942
+ message="no fixture source configured for this Databricks adapter instance",
1943
+ )
1944
+ if not fixture.exists():
1945
+ return RunResult(
1946
+ status=RunStatus.FAILED,
1947
+ message=f"fixture not found: {fixture}",
1948
+ )
1949
+
1950
+ start = time.monotonic()
1951
+ catalog = self._catalog()
1952
+ schema = plan.target.schema_name
1953
+ table = plan.target.table_name
1954
+ target_fq = _fq(catalog, schema, table)
1955
+ volume = self.profile.get("ingest_volume", DEFAULT_INGEST_VOLUME)
1956
+
1957
+ try:
1958
+ self.create_schema(schema)
1959
+ remote_path = self._upload_to_volume(fixture, volume, source.name, fixture.name)
1960
+ self._execute(
1961
+ f"CREATE TABLE IF NOT EXISTS {target_fq} "
1962
+ f"AS SELECT * FROM read_files(?, format => 'csv', header => true) LIMIT 0",
1963
+ parameters=[remote_path],
1964
+ )
1965
+ self._execute(
1966
+ f"COPY INTO {target_fq} "
1967
+ f"FROM (SELECT * FROM read_files(?, format => 'csv', header => true)) "
1968
+ f"FILEFORMAT = CSV "
1969
+ f"COPY_OPTIONS ('mergeSchema' = 'true')",
1970
+ parameters=[remote_path],
1971
+ )
1972
+ rows = self._scalar(f"SELECT COUNT(*) FROM {target_fq}")
1973
+ except Exception as e:
1974
+ return RunResult(
1975
+ status=RunStatus.FAILED,
1976
+ message=f"ingest failed: {e}",
1977
+ )
1978
+
1979
+ return RunResult(
1980
+ status=RunStatus.SUCCESS,
1981
+ message=f"ingested {rows} rows into {catalog}.{schema}.{table}",
1982
+ rows_affected=int(rows) if rows is not None else None,
1983
+ duration_ms=int((time.monotonic() - start) * 1000),
1984
+ )
1985
+
1986
+ # ---- landing_zone mode (real production path) -----------------------
1987
+
1988
+ def _ingest_landing_zone(self, source: Source, plan: IngestionPlan) -> RunResult:
1989
+ """Drive bronze ingest off :class:`LandingStorage` (ADR 0046 phase 3).
1990
+
1991
+ The warehouse reads the landing bytes directly via ``read_files()``
1992
+ against the URL the storage layer hands back — no pod-mediated
1993
+ upload to Unity Catalog Volumes. ``move_to_raw`` rotates the file
1994
+ atomically (rename on HNS-ADLS); the URL the warehouse consumes
1995
+ is the post-rotation one, so the read targets a stable path.
1996
+ """
1997
+ ingestion = plan.effective_ingestion()
1998
+ assert ingestion is not None
1999
+
2000
+ storage = self.landing_storage()
2001
+ refs = list(storage.list(source.name, plan.table.physical_name))
2002
+ if not refs:
2003
+ return RunResult(
2004
+ status=RunStatus.SKIPPED,
2005
+ message=(f"no landing files for {source.name}/{plan.table.physical_name}"),
2006
+ )
2007
+
2008
+ start = time.monotonic()
2009
+ catalog = self._catalog()
2010
+ schema = plan.target.schema_name
2011
+ table = plan.target.table_name
2012
+ target_fq = _fq(catalog, schema, table)
2013
+ today = datetime.now(UTC).date()
2014
+
2015
+ self.create_schema(schema)
2016
+
2017
+ # Issue #366 — refuse to MERGE into a pre-ADR-0124 target.
2018
+ # ``WHEN MATCHED THEN UPDATE SET *`` expands against the
2019
+ # target's columns; a target carrying ``_dca_*`` meta cols
2020
+ # would fail every file's MERGE and let bronze report 0 rows
2021
+ # silently. The per-file ``except Exception`` loop in
2022
+ # ``runner.py`` would catch and continue, masking the cause.
2023
+ stale_meta = self._detect_stale_meta_columns(target_fq)
2024
+ if stale_meta:
2025
+ return RunResult(
2026
+ status=RunStatus.FAILED,
2027
+ message=str(StaleSchemaError(target_fq, stale_meta)),
2028
+ )
2029
+
2030
+ files_ingested = 0
2031
+ total_rows = 0
2032
+ errors: list[str] = []
2033
+
2034
+ for ref in sorted(refs, key=lambda r: r.name):
2035
+ try:
2036
+ rows = self._merge_one_file(
2037
+ storage=storage,
2038
+ ref=ref,
2039
+ target_fq=target_fq,
2040
+ fmt=ingestion.format.value,
2041
+ dedupe_key=list(ingestion.dedupe_key),
2042
+ deleted_flag=ingestion.deleted_flag,
2043
+ today=today,
2044
+ json_shape=ingestion.json_shape,
2045
+ )
2046
+ except Exception as e:
2047
+ errors.append(f"{ref.name}: {e}")
2048
+ continue
2049
+ files_ingested += 1
2050
+ total_rows += rows
2051
+
2052
+ status = RunStatus.SUCCESS if not errors else RunStatus.FAILED
2053
+ return RunResult(
2054
+ status=status,
2055
+ message=(
2056
+ f"ingested {files_ingested} files ({total_rows} rows) "
2057
+ f"into {catalog}.{schema}.{table}"
2058
+ + (f" — errors: {len(errors)}; first: {errors[0]}" if errors else "")
2059
+ ),
2060
+ rows_affected=total_rows,
2061
+ duration_ms=int((time.monotonic() - start) * 1000),
2062
+ )
2063
+
2064
+ def _merge_one_file(
2065
+ self,
2066
+ *,
2067
+ storage: Any,
2068
+ ref: Any,
2069
+ target_fq: str,
2070
+ fmt: str,
2071
+ dedupe_key: list[str],
2072
+ deleted_flag: str | None,
2073
+ today: date,
2074
+ json_shape: str = "array",
2075
+ ) -> int:
2076
+ """Move landing → _raw, MERGE (or INSERT) the warehouse-native URL.
2077
+
2078
+ Phase 3: the warehouse reads from
2079
+ ``storage.url_for_native_read(post_move_ref)`` directly. No
2080
+ upload-to-volume — the bytes never traverse the control-plane
2081
+ pod for ADLS-backed storage. The original landing basename is
2082
+ preserved as ``source_file`` for audit attribution.
2083
+
2084
+ ``json_shape`` selects between ``multiLine=true`` (array /
2085
+ single-object files) and ``multiLine=false`` (NDJSON, the
2086
+ default integration-platform output). See
2087
+ :func:`build_staging_select_sql` for the full mapping.
2088
+ """
2089
+ landing_name = ref.name
2090
+ trace_id = uuid.uuid4().hex
2091
+ raw_ref = storage.move_to_raw(ref, day=today)
2092
+ remote = storage.url_for_native_read(raw_ref)
2093
+
2094
+ source_select = build_staging_select_sql(
2095
+ volume_path=remote,
2096
+ fmt=fmt,
2097
+ source_file=landing_name,
2098
+ trace_id=trace_id,
2099
+ deleted_flag=deleted_flag,
2100
+ today=today,
2101
+ json_shape=json_shape,
2102
+ )
2103
+
2104
+ # First run creates an empty table with the right schema.
2105
+ self._execute(
2106
+ build_create_table_sql(
2107
+ target_fq=target_fq,
2108
+ source_select=source_select,
2109
+ )
2110
+ )
2111
+
2112
+ if dedupe_key:
2113
+ sql = build_merge_sql(
2114
+ target_fq=target_fq,
2115
+ source_select=source_select,
2116
+ dedupe_key=dedupe_key,
2117
+ )
2118
+ else:
2119
+ sql = build_append_sql(
2120
+ target_fq=target_fq,
2121
+ source_select=source_select,
2122
+ )
2123
+ self._execute(sql)
2124
+
2125
+ rows = self._scalar(f"SELECT COUNT(*) FROM ({source_select}) s")
2126
+ return int(rows) if rows is not None else 0
2127
+
2128
+ def query(self, sql: str, parameters: list[Any] | None = None) -> QueryResult:
2129
+ """Run a read-only query via the Statement Execution API.
2130
+
2131
+ Column names aren't returned by the SDK's simple data_array path;
2132
+ we fall back to `col_1, col_2, …` if column metadata isn't exposed.
2133
+ """
2134
+ from databricks.sdk.service.sql import StatementState
2135
+
2136
+ ws = self._workspace()
2137
+ response = ws.statement_execution.execute_statement(
2138
+ statement=sql,
2139
+ warehouse_id=self._warehouse_id(),
2140
+ parameters=[{"value": p, "type": "STRING"} for p in (parameters or [])] or None,
2141
+ wait_timeout="30s",
2142
+ )
2143
+ state = response.status.state if response.status else None
2144
+ if state != StatementState.SUCCEEDED:
2145
+ raise RuntimeError(
2146
+ f"Query did not succeed: state={state}, "
2147
+ f"error={getattr(response.status, 'error', None)}"
2148
+ )
2149
+
2150
+ # Try to pull column names from the manifest; fall back gracefully.
2151
+ col_names: list[str]
2152
+ manifest = getattr(response, "manifest", None)
2153
+ if manifest and manifest.schema and manifest.schema.columns:
2154
+ col_names = [c.name for c in manifest.schema.columns]
2155
+ else:
2156
+ width = (
2157
+ len(response.result.data_array[0])
2158
+ if response.result and response.result.data_array
2159
+ else 0
2160
+ )
2161
+ col_names = [f"col_{i}" for i in range(width)]
2162
+
2163
+ data = [tuple(r) for r in (response.result.data_array or [])] if response.result else []
2164
+ return QueryResult(columns=col_names, rows=data)
2165
+
2166
+ def execute(self, sql: str, parameters: list[Any] | None = None) -> RunResult:
2167
+ """Run a write statement via the Statement Execution API."""
2168
+ start = time.monotonic()
2169
+ try:
2170
+ self._execute(sql, parameters=parameters)
2171
+ except Exception as e:
2172
+ return RunResult(
2173
+ status=RunStatus.FAILED,
2174
+ message=f"execute failed: {e}",
2175
+ duration_ms=int((time.monotonic() - start) * 1000),
2176
+ )
2177
+ return RunResult(
2178
+ status=RunStatus.SUCCESS,
2179
+ message="ok",
2180
+ duration_ms=int((time.monotonic() - start) * 1000),
2181
+ )
2182
+
2183
+ def describe_table(self, ref: TableRef) -> TableDescription:
2184
+ catalog = self._catalog()
2185
+ cols_rows = self._execute(
2186
+ "SELECT column_name, full_data_type, is_nullable "
2187
+ "FROM system.information_schema.columns "
2188
+ "WHERE table_catalog = ? AND table_schema = ? AND table_name = ? "
2189
+ "ORDER BY ordinal_position",
2190
+ parameters=[catalog, ref.schema_name, ref.table_name],
2191
+ )
2192
+ columns = [
2193
+ ColumnDescription(name=n, data_type=t, nullable=(nullable == "YES"))
2194
+ for (n, t, nullable) in (cols_rows or [])
2195
+ ]
2196
+ fq_table = _fq(catalog, ref.schema_name, ref.table_name)
2197
+ row_count = self._row_count_describe_detail(fq_table)
2198
+ if row_count is None:
2199
+ row_count = self._row_count_count_star(fq_table)
2200
+ return TableDescription(ref=ref, row_count=row_count, columns=columns)
2201
+
2202
+ def describe_annotations(self, ref: TableRef) -> LiveTableAnnotations:
2203
+ """Read live comments + tags from Unity Catalog (ADR 0131 verify).
2204
+
2205
+ All four reads go through ``system.information_schema`` (the same
2206
+ metastore view ``describe_table`` uses), so no new auth surface and
2207
+ no Spark-cluster dependency — a SQL warehouse is enough:
2208
+
2209
+ * ``tables.comment`` / ``columns.comment`` → comments
2210
+ * ``table_tags`` / ``column_tags`` → governance tags
2211
+
2212
+ Tags are returned verbatim; the verify engine scopes the comparison
2213
+ to the contract-managed ``kiri_*`` keys so unrelated operator tags are
2214
+ never mistaken for drift. ``tags_supported`` is always ``True`` here.
2215
+ """
2216
+ catalog = self._catalog()
2217
+
2218
+ table_comment: str | None = None
2219
+ tbl_rows = self._execute(
2220
+ "SELECT comment FROM system.information_schema.tables "
2221
+ "WHERE table_catalog = ? AND table_schema = ? AND table_name = ?",
2222
+ parameters=[catalog, ref.schema_name, ref.table_name],
2223
+ )
2224
+ if tbl_rows and tbl_rows[0]:
2225
+ table_comment = tbl_rows[0][0]
2226
+
2227
+ col_rows = self._execute(
2228
+ "SELECT column_name, comment FROM system.information_schema.columns "
2229
+ "WHERE table_catalog = ? AND table_schema = ? AND table_name = ?",
2230
+ parameters=[catalog, ref.schema_name, ref.table_name],
2231
+ )
2232
+ columns: dict[str, LiveColumnAnnotation] = {}
2233
+ for name, comment in col_rows or []:
2234
+ columns[name] = LiveColumnAnnotation(comment=comment, tags={})
2235
+
2236
+ tbl_tag_rows = self._execute(
2237
+ "SELECT tag_name, tag_value FROM system.information_schema.table_tags "
2238
+ "WHERE catalog_name = ? AND schema_name = ? AND table_name = ?",
2239
+ parameters=[catalog, ref.schema_name, ref.table_name],
2240
+ )
2241
+ table_tags = {k: ("" if v is None else str(v)) for (k, v) in (tbl_tag_rows or [])}
2242
+
2243
+ col_tag_rows = self._execute(
2244
+ "SELECT column_name, tag_name, tag_value "
2245
+ "FROM system.information_schema.column_tags "
2246
+ "WHERE catalog_name = ? AND schema_name = ? AND table_name = ?",
2247
+ parameters=[catalog, ref.schema_name, ref.table_name],
2248
+ )
2249
+ for col_name, tag_name, tag_value in col_tag_rows or []:
2250
+ existing = columns.get(col_name)
2251
+ tag_val = "" if tag_value is None else str(tag_value)
2252
+ if existing is None:
2253
+ # A column with a tag but no row in the columns read is
2254
+ # unexpected, but record it rather than drop the tag silently.
2255
+ columns[col_name] = LiveColumnAnnotation(comment=None, tags={tag_name: tag_val})
2256
+ else:
2257
+ columns[col_name] = existing.model_copy(
2258
+ update={"tags": {**existing.tags, tag_name: tag_val}}
2259
+ )
2260
+
2261
+ return LiveTableAnnotations(
2262
+ ref=ref,
2263
+ table_comment=table_comment,
2264
+ table_tags=table_tags,
2265
+ columns=columns,
2266
+ tags_supported=True,
2267
+ )
2268
+
2269
+ def _row_count_describe_detail(self, fq_table: str) -> int | None:
2270
+ """N3: prefer ``DESCRIBE DETAIL`` when ``numRecords`` is present.
2271
+
2272
+ Recent Databricks runtimes (and Delta tables with stats
2273
+ collection enabled) include ``numRecords`` in
2274
+ ``DESCRIBE DETAIL``'s output. That's a metadata read — bounded
2275
+ cost regardless of table size, vs ``COUNT(*)`` which can scan
2276
+ billions of files on stats-disabled tables. Falls back to
2277
+ ``COUNT(*)`` when the field is absent.
2278
+
2279
+ Returns ``None`` when DESCRIBE DETAIL doesn't yield a row,
2280
+ the column is missing, or the value is ``NULL`` — caller
2281
+ falls through to ``_row_count_count_star``.
2282
+ """
2283
+ try:
2284
+ rows = self._execute(f"DESCRIBE DETAIL {fq_table}")
2285
+ except Exception:
2286
+ return None
2287
+ if not rows or not rows[0]:
2288
+ return None
2289
+ # The result columns vary by Databricks Runtime version.
2290
+ # We can't introspect column names from the SDK's data_array
2291
+ # response without manifest metadata; instead we look for an
2292
+ # int-typed value with magnitude consistent with a row count.
2293
+ # The shape we expect is: (format, id, name, description,
2294
+ # location, ..., numFiles, sizeInBytes, ..., numRecords?).
2295
+ # Newer DBR returns ``numRecords`` as a typed long.
2296
+ # Heuristic: scan the row for a non-negative integer that's
2297
+ # reasonably-bounded (< 2^60); pick the *last* such value
2298
+ # because numRecords (when present) is appended after older
2299
+ # columns. False positives are caught by the fallback path —
2300
+ # COUNT(*) is the source of truth, this is just an
2301
+ # optimisation when we can avoid it cheaply.
2302
+ candidate: int | None = None
2303
+ for value in rows[0]:
2304
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
2305
+ candidate = value
2306
+ return candidate
2307
+
2308
+ def _row_count_count_star(self, fq_table: str) -> int | None:
2309
+ """Slow-path row count — Delta optimises ``SELECT COUNT(*)``
2310
+ to a metadata aggregation in most cases, but very large
2311
+ non-stats-collected tables still pay file-scan cost. Wrapped
2312
+ so a permission denial doesn't block ``describe_table``.
2313
+ """
2314
+ try:
2315
+ value = self._scalar(f"SELECT COUNT(*) FROM {fq_table}")
2316
+ return int(value) if value is not None else None
2317
+ except Exception:
2318
+ return None
2319
+
2320
+ def supports(self, capability: str) -> bool:
2321
+ return capability in {
2322
+ "unity-catalog",
2323
+ "delta-lake",
2324
+ "managed-volumes",
2325
+ "sql-merge",
2326
+ "time-travel",
2327
+ }
2328
+
2329
+ # ---- maintenance -----------------------------------------------------
2330
+
2331
+ def optimize(self, source_name: str, table_physical_name: str) -> RunResult:
2332
+ catalog = self._catalog()
2333
+ target = _fq(catalog, f"bronze_{source_name}", table_physical_name)
2334
+ start = time.monotonic()
2335
+ try:
2336
+ self._execute(f"OPTIMIZE {target}")
2337
+ except Exception as e:
2338
+ return RunResult(status=RunStatus.FAILED, message=f"OPTIMIZE failed: {e}")
2339
+ return RunResult(
2340
+ status=RunStatus.SUCCESS,
2341
+ message=f"optimized {target}",
2342
+ duration_ms=int((time.monotonic() - start) * 1000),
2343
+ )
2344
+
2345
+ def vacuum(
2346
+ self,
2347
+ source_name: str,
2348
+ table_physical_name: str,
2349
+ *,
2350
+ retention_hours: int = 168,
2351
+ dry_run: bool = False,
2352
+ enforce_retention: bool = True,
2353
+ ) -> RunResult:
2354
+ catalog = self._catalog()
2355
+ target = _fq(catalog, f"bronze_{source_name}", table_physical_name)
2356
+ clause = f"RETAIN {retention_hours} HOURS"
2357
+ suffix = " DRY RUN" if dry_run else ""
2358
+ start = time.monotonic()
2359
+ # N4: ``SET spark.databricks.delta.retentionDurationCheck.enabled
2360
+ # = false`` is a session-scoped flag — without an explicit
2361
+ # restore, the next statement that re-uses the warehouse
2362
+ # session inherits the relaxed check. Always restore the
2363
+ # default ``true`` after a vacuum that flipped it, even when
2364
+ # the VACUUM itself raises.
2365
+ flag_was_flipped = False
2366
+ try:
2367
+ if not enforce_retention:
2368
+ self._execute("SET spark.databricks.delta.retentionDurationCheck.enabled = false")
2369
+ flag_was_flipped = True
2370
+ self._execute(f"VACUUM {target} {clause}{suffix}")
2371
+ except Exception as e:
2372
+ return RunResult(status=RunStatus.FAILED, message=f"VACUUM failed: {e}")
2373
+ finally:
2374
+ if flag_was_flipped:
2375
+ # Best-effort restore — a failure here is logged via
2376
+ # ``_execute``'s normal path but does not turn a
2377
+ # successful vacuum into a failed RunResult.
2378
+ import contextlib
2379
+
2380
+ with contextlib.suppress(Exception):
2381
+ self._execute(
2382
+ "SET spark.databricks.delta.retentionDurationCheck.enabled = true"
2383
+ )
2384
+ verb = "would vacuum" if dry_run else "vacuumed"
2385
+ return RunResult(
2386
+ status=RunStatus.SUCCESS,
2387
+ message=f"{verb} {target} (retention={retention_hours}h)",
2388
+ duration_ms=int((time.monotonic() - start) * 1000),
2389
+ )
2390
+
2391
+ def apply_retention(
2392
+ self,
2393
+ table_fqn: str,
2394
+ days: int,
2395
+ *,
2396
+ dry_run: bool = False,
2397
+ ) -> RetentionReport:
2398
+ """Dispatch Delta VACUUM + ``delta.logRetentionDuration`` (ADR 0114 §4).
2399
+
2400
+ ``table_fqn`` is the canonical ``<schema>.<table>``; Databricks
2401
+ runs in the configured catalog (``profile['catalog']``) so the
2402
+ adapter prefixes it on the way to SQL. Retention is recorded
2403
+ twice — as a TBLPROPERTIES setting so Delta's own expiry math
2404
+ respects the contract value, and as a VACUUM that physically
2405
+ removes the now-out-of-window files. Both are idempotent.
2406
+ """
2407
+ if days <= 0:
2408
+ raise ValueError(f"retention days must be positive, got {days!r}")
2409
+ catalog = self._catalog()
2410
+ # Accept either ``schema.table`` or a pre-qualified
2411
+ # ``catalog.schema.table`` — the latter is what operators
2412
+ # might paste from describe output.
2413
+ parts = table_fqn.split(".")
2414
+ if len(parts) == 2:
2415
+ qualified = f"`{catalog}`.`{parts[0]}`.`{parts[1]}`"
2416
+ elif len(parts) == 3:
2417
+ qualified = ".".join(f"`{p}`" for p in parts)
2418
+ else:
2419
+ raise ValueError(
2420
+ f"table_fqn must be 'schema.table' or 'catalog.schema.table', got {table_fqn!r}"
2421
+ )
2422
+ retention_hours = days * 24
2423
+ set_tblprops = (
2424
+ f"ALTER TABLE {qualified} SET TBLPROPERTIES "
2425
+ f"('delta.logRetentionDuration' = 'interval {days} days')"
2426
+ )
2427
+ vacuum_stmt = f"VACUUM {qualified} RETAIN {retention_hours} HOURS"
2428
+ if dry_run:
2429
+ vacuum_stmt += " DRY RUN"
2430
+ commands = [set_tblprops, vacuum_stmt]
2431
+ if dry_run:
2432
+ return RetentionReport(
2433
+ action=RetentionAction.PREVIEWED,
2434
+ mechanism="VACUUM",
2435
+ table_fqn=table_fqn,
2436
+ days=days,
2437
+ commands=commands,
2438
+ message=f"would apply retention={days}d ({retention_hours}h) on {table_fqn}",
2439
+ )
2440
+ start = time.monotonic()
2441
+ try:
2442
+ self._execute(set_tblprops)
2443
+ self._execute(vacuum_stmt)
2444
+ except Exception as e:
2445
+ return RetentionReport(
2446
+ action=RetentionAction.FAILED,
2447
+ mechanism="VACUUM",
2448
+ table_fqn=table_fqn,
2449
+ days=days,
2450
+ commands=commands,
2451
+ message=f"retention dispatch failed: {e}",
2452
+ duration_ms=int((time.monotonic() - start) * 1000),
2453
+ )
2454
+ return RetentionReport(
2455
+ action=RetentionAction.APPLIED,
2456
+ mechanism="VACUUM",
2457
+ table_fqn=table_fqn,
2458
+ days=days,
2459
+ commands=commands,
2460
+ message=f"applied retention={days}d ({retention_hours}h) on {table_fqn}",
2461
+ duration_ms=int((time.monotonic() - start) * 1000),
2462
+ )
2463
+
2464
+ def history(
2465
+ self, source_name: str, table_physical_name: str, limit: int = 10
2466
+ ) -> list[dict[str, Any]]:
2467
+ """Return DESCRIBE HISTORY rows as dicts."""
2468
+ catalog = self._catalog()
2469
+ target = _fq(catalog, f"bronze_{source_name}", table_physical_name)
2470
+ try:
2471
+ rows = self._execute(f"DESCRIBE HISTORY {target} LIMIT {int(limit)}")
2472
+ except Exception:
2473
+ return []
2474
+ if not rows:
2475
+ return []
2476
+ # DESCRIBE HISTORY returns named columns; the statement API doesn't
2477
+ # give us names here, so we shape the rows generically.
2478
+ return [
2479
+ {"version": r[0], "timestamp": r[1], "operation": r[5]} if len(r) >= 6 else {"row": r}
2480
+ for r in rows
2481
+ ]
2482
+
2483
+ # ---- internals -------------------------------------------------------
2484
+
2485
+ def _catalog(self) -> str:
2486
+ return str(self.profile.get("catalog", DEFAULT_CATALOG))
2487
+
2488
+ def _warehouse_id(self) -> str:
2489
+ wh = self.profile.get("warehouse_id") or os.environ.get("DATABRICKS_WAREHOUSE_ID")
2490
+ if not wh:
2491
+ raise RuntimeError(
2492
+ "Databricks adapter requires warehouse_id in profile "
2493
+ "or DATABRICKS_WAREHOUSE_ID in env."
2494
+ )
2495
+ return str(wh)
2496
+
2497
+ def _workspace(self) -> Any:
2498
+ """Construct a WorkspaceClient with one of three auth modes.
2499
+
2500
+ Priority (per profile / env):
2501
+
2502
+ 1. **OAuth M2M** (service principal) — ``client_id`` + ``client_secret``.
2503
+ Production-grade; rotation lives in the IdP.
2504
+ 2. **PAT** (legacy) — ``token``. Easiest dev setup; rotation
2505
+ manual via Databricks UI.
2506
+ 3. **SDK default auth chain** — neither in profile nor env;
2507
+ SDK falls back to azure-cli / databricks-cli / managed
2508
+ identity. Useful for AKS pods running with workload
2509
+ identity bound to the SP.
2510
+
2511
+ S9: when ``KIRIMANA_DATABRICKS_AUTH_MODE`` is set, the
2512
+ constructor asserts that the auto-picked mode matches and
2513
+ raises if it doesn't. Lets operators express the expected
2514
+ deploy-time auth posture (e.g. "this Helm release MUST run
2515
+ OAuth M2M, never PAT") and catch drift early.
2516
+ """
2517
+ if self._client is None:
2518
+ from databricks.sdk import WorkspaceClient
2519
+
2520
+ picked = self._auth_mode()
2521
+ expected = os.environ.get("KIRIMANA_DATABRICKS_AUTH_MODE")
2522
+ if expected and expected != picked:
2523
+ raise RuntimeError(
2524
+ f"KIRIMANA_DATABRICKS_AUTH_MODE={expected!r} but adapter "
2525
+ f"would use {picked!r}. Either remove the env, change the "
2526
+ "profile / env credentials to match, or set the env to "
2527
+ f"{picked!r} after verifying."
2528
+ )
2529
+
2530
+ host = self.profile.get("host") or os.environ.get("DATABRICKS_HOST")
2531
+ client_id = self.profile.get("client_id") or os.environ.get("DATABRICKS_CLIENT_ID")
2532
+ client_secret = self.profile.get("client_secret") or os.environ.get(
2533
+ "DATABRICKS_CLIENT_SECRET"
2534
+ )
2535
+ token = self.profile.get("token") or os.environ.get("DATABRICKS_TOKEN")
2536
+
2537
+ if client_id and client_secret:
2538
+ self._client = WorkspaceClient(
2539
+ host=host,
2540
+ client_id=client_id,
2541
+ client_secret=client_secret,
2542
+ )
2543
+ elif token:
2544
+ self._client = WorkspaceClient(host=host, token=token)
2545
+ else:
2546
+ # Default chain: azure-cli, workload identity, ~/.databrickscfg.
2547
+ self._client = WorkspaceClient(host=host)
2548
+ return self._client
2549
+
2550
+ def _wait_timeout(self) -> str:
2551
+ """Configurable per-statement wait timeout.
2552
+
2553
+ Default 30s preserves legacy behaviour. Big MERGE on 10M+ rows
2554
+ easily exceeds — operators raise this in kiri.yml profile to
2555
+ match their warehouse + data size:
2556
+
2557
+ targets:
2558
+ prod:
2559
+ adapter: databricks
2560
+ wait_timeout: "180s" # 3 minutes
2561
+
2562
+ Databricks API caps at ``50s`` for ``wait_timeout`` parameter
2563
+ on /statement-execution; longer values trigger async-mode
2564
+ statements where the SDK polls under the hood. The SDK
2565
+ handles the polling transparently.
2566
+ """
2567
+ value = self.profile.get("wait_timeout", "30s")
2568
+ # Tolerate plain ints — convert to seconds.
2569
+ if isinstance(value, int | float):
2570
+ return f"{int(value)}s"
2571
+ return str(value)
2572
+
2573
+ @staticmethod
2574
+ def _db_type_for(value: Any) -> str:
2575
+ """Map a Python value to Databricks Statement API parameter type.
2576
+
2577
+ Falls back to STRING for unknown types — Databricks coerces
2578
+ most things server-side, but typed ints/booleans/dates avoid
2579
+ a class of MERGE-predicate bugs (``"5" = 5`` is false in
2580
+ strict mode).
2581
+ """
2582
+ # bool first — bool is a subclass of int.
2583
+ if isinstance(value, bool):
2584
+ return "BOOLEAN"
2585
+ if isinstance(value, int):
2586
+ # Python ints are unbounded; Databricks Spark INT is signed
2587
+ # 32-bit (-2^31..2^31-1). Many real-world ID columns (MSSQL
2588
+ # ``bigint``, epoch-ns timestamps, accumulator counters)
2589
+ # exceed that range, and an out-of-range INT parameter on
2590
+ # the Statement Execution API returns a hard cast error.
2591
+ # BIGINT (signed 64-bit) is a safe superset; Spark casts
2592
+ # BIGINT → INT implicitly where the target column is
2593
+ # narrower, so widening here costs nothing and prevents
2594
+ # overflow on the load-bearing path. Live-found 2026-05-23.
2595
+ #
2596
+ # CODEX-FINAL-V2 P2: refuse values outside the 64-bit range
2597
+ # rather than serialising a BIGINT that the Statement API
2598
+ # will reject server-side with an opaque cast error. The
2599
+ # caller can switch to Decimal/string binding if they
2600
+ # genuinely need wider integers (rare for migration data).
2601
+ if not -(2**63) <= value <= 2**63 - 1:
2602
+ raise OverflowError(
2603
+ f"int value {value!r} is outside Spark BIGINT range "
2604
+ f"(-2**63..2**63-1); rebind as a Decimal or string "
2605
+ f"parameter, or narrow the value before passing it to "
2606
+ f"the Databricks Statement Execution API."
2607
+ )
2608
+ return "BIGINT"
2609
+ if isinstance(value, float):
2610
+ return "DOUBLE"
2611
+ if isinstance(value, datetime):
2612
+ return "TIMESTAMP"
2613
+ if isinstance(value, date):
2614
+ return "DATE"
2615
+ # str + everything else → STRING.
2616
+ return "STRING"
2617
+
2618
+ def _execute(
2619
+ self, sql: str, parameters: list[Any] | None = None
2620
+ ) -> list[tuple[Any, ...]] | None:
2621
+ """Run a statement with typed params + transient-error retry.
2622
+
2623
+ Retries up to 3 times on 429 / 503 / connection errors with
2624
+ exponential backoff (0.5s, 1s, 2s). Hard errors (4xx that's
2625
+ not 429, semantic SQL failures) raise immediately — retrying
2626
+ a CREATE TABLE that's malformed won't help.
2627
+
2628
+ ADR 0049: prepends a structured trace-id comment so the
2629
+ Databricks audit log carries ``kiri_trace_id`` /
2630
+ ``apply_id`` / ``release_sha`` / ``caller`` in
2631
+ ``statement_text``. The Kirimana side issued the same id
2632
+ in its own audit; ``kiri audit join`` matches the two.
2633
+ """
2634
+ from databricks.sdk.service.sql import (
2635
+ StatementParameterListItem,
2636
+ StatementState,
2637
+ )
2638
+
2639
+ # Prepend the trace comment first; the kirimana-prepended
2640
+ # comment must lead so the audit-log parser doesn't
2641
+ # collide with user-authored ``/* ... */`` content downstream
2642
+ # (e.g. dbt-rendered SQL that already starts with comments).
2643
+ sql = current_trace().sql_comment() + " " + sql
2644
+
2645
+ # The Databricks Statement Execution API supports only *named*
2646
+ # parameter markers (``:p0``); positional ``?`` is sent unbound
2647
+ # and rejected with UNBOUND_SQL_PARAMETER. Callers use ``?``, so
2648
+ # rewrite each positional marker to ``:pN`` in lockstep with the
2649
+ # named params here — ``?`` inside single-quoted literals is left
2650
+ # untouched.
2651
+ params_list = list(parameters or [])
2652
+ if params_list:
2653
+ out: list[str] = []
2654
+ in_str = False
2655
+ marker = 0
2656
+ for ch in sql:
2657
+ if ch == "'":
2658
+ in_str = not in_str
2659
+ out.append(ch)
2660
+ elif ch == "?" and not in_str:
2661
+ out.append(f":p{marker}")
2662
+ marker += 1
2663
+ else:
2664
+ out.append(ch)
2665
+ if marker != len(params_list):
2666
+ raise ValueError(
2667
+ f"parameter count mismatch: SQL has {marker} '?' "
2668
+ f"markers but {len(params_list)} parameters were given."
2669
+ )
2670
+ sql = "".join(out)
2671
+
2672
+ # The SDK serialises each item via ``.as_dict()``; it must be a
2673
+ # ``StatementParameterListItem``, not a raw dict.
2674
+ typed_params = [
2675
+ StatementParameterListItem(
2676
+ name=f"p{i}",
2677
+ value=str(p) if not isinstance(p, str) else p,
2678
+ type=self._db_type_for(p),
2679
+ )
2680
+ for i, p in enumerate(params_list)
2681
+ ] or None
2682
+
2683
+ ws = self._workspace()
2684
+ self._gate_rate_limit()
2685
+ max_attempts = self._retry_attempts
2686
+ backoff_base = self._retry_backoff_base
2687
+ last_error: Exception | None = None
2688
+ attempts_used = 0
2689
+ started_at = time.monotonic()
2690
+ response = None
2691
+ for attempt in range(max_attempts):
2692
+ attempts_used = attempt + 1
2693
+ try:
2694
+ response = ws.statement_execution.execute_statement(
2695
+ statement=sql,
2696
+ warehouse_id=self._warehouse_id(),
2697
+ catalog=self._catalog(),
2698
+ parameters=typed_params,
2699
+ wait_timeout=self._wait_timeout(),
2700
+ )
2701
+ break
2702
+ except Exception as e:
2703
+ if not self._is_transient(e):
2704
+ self._emit_audit(
2705
+ sql=sql,
2706
+ duration_ms=int((time.monotonic() - started_at) * 1000),
2707
+ attempts=attempts_used,
2708
+ status="failed",
2709
+ error_class=type(e).__name__,
2710
+ )
2711
+ raise
2712
+ last_error = e
2713
+ if attempt < max_attempts - 1:
2714
+ time.sleep(backoff_base * (2**attempt))
2715
+ else:
2716
+ # All retries exhausted on transient errors.
2717
+ self._emit_audit(
2718
+ sql=sql,
2719
+ duration_ms=int((time.monotonic() - started_at) * 1000),
2720
+ attempts=attempts_used,
2721
+ status="failed",
2722
+ error_class=type(last_error).__name__ if last_error else "Unknown",
2723
+ )
2724
+ raise RuntimeError(
2725
+ f"Statement failed after {max_attempts} attempts (transient errors): {last_error}"
2726
+ ) from last_error
2727
+
2728
+ assert response is not None
2729
+ state = response.status.state if response.status else None
2730
+ if state != StatementState.SUCCEEDED:
2731
+ self._emit_audit(
2732
+ sql=sql,
2733
+ duration_ms=int((time.monotonic() - started_at) * 1000),
2734
+ attempts=attempts_used,
2735
+ status="failed",
2736
+ error_class="UnsuccessfulState",
2737
+ )
2738
+ raise RuntimeError(
2739
+ f"Statement did not succeed: state={state}, "
2740
+ f"error={getattr(response.status, 'error', None)}"
2741
+ )
2742
+
2743
+ self._emit_audit(
2744
+ sql=sql,
2745
+ duration_ms=int((time.monotonic() - started_at) * 1000),
2746
+ attempts=attempts_used,
2747
+ status="success",
2748
+ error_class=None,
2749
+ )
2750
+
2751
+ if response.result and response.result.data_array:
2752
+ return [tuple(row) for row in response.result.data_array]
2753
+ return None
2754
+
2755
+ def _emit_audit(
2756
+ self,
2757
+ *,
2758
+ sql: str,
2759
+ duration_ms: int,
2760
+ attempts: int,
2761
+ status: str,
2762
+ error_class: str | None,
2763
+ ) -> None:
2764
+ """Append one ADR 0092 audit record rendered as log + JSONL."""
2765
+
2766
+ enabled, _disabled_reason = self._audit_enabled()
2767
+ if not enabled:
2768
+ if not self._audit_disabled_emitted:
2769
+ self._audit_disabled_emitted = True
2770
+ _emit_databricks_audit_record(
2771
+ profile=self.profile,
2772
+ paths=self._audit_paths,
2773
+ event="AUDIT_DISABLED",
2774
+ target="?",
2775
+ status="failed",
2776
+ duration_ms=0,
2777
+ attempts=0,
2778
+ warehouse_id=self.profile.get("warehouse_id"),
2779
+ via="adapter",
2780
+ user=self._current_user(),
2781
+ error_class=None,
2782
+ statement_preview=_redact_preview(_strip_leading_trace_comment(sql)),
2783
+ )
2784
+ return
2785
+ _emit_databricks_audit_record(
2786
+ profile=self.profile,
2787
+ paths=self._audit_paths,
2788
+ event=databricks_audit_event(sql),
2789
+ target=databricks_audit_target(sql),
2790
+ status=status,
2791
+ duration_ms=duration_ms,
2792
+ attempts=attempts,
2793
+ warehouse_id=self.profile.get("warehouse_id"),
2794
+ via="adapter",
2795
+ user=self._current_user(),
2796
+ error_class=error_class,
2797
+ statement_preview=_redact_preview(_strip_leading_trace_comment(sql)),
2798
+ )
2799
+
2800
+ def _write_audit_record(self, record: _AuditRecord) -> None:
2801
+ _write_audit_record(self._audit_paths, record)
2802
+
2803
+ def _write_audit_line(self, record: _AuditRecord) -> None:
2804
+ _write_audit_line(self._audit_paths, record)
2805
+
2806
+ def _write_audit_jsonl(self, record: _AuditRecord) -> None:
2807
+ _write_audit_jsonl(self._audit_paths, record)
2808
+
2809
+ def _audit_enabled(self) -> tuple[bool, str | None]:
2810
+ return databricks_audit_enabled(self.profile)
2811
+
2812
+ @staticmethod
2813
+ def _resolve_audit_paths(profile: dict[str, Any]) -> _AuditPaths:
2814
+ return _resolve_databricks_audit_paths(profile)
2815
+
2816
+ def _current_user(self) -> str:
2817
+ if self._audit_user is not None:
2818
+ return self._audit_user
2819
+ try:
2820
+ ws = self._workspace()
2821
+ response = ws.statement_execution.execute_statement(
2822
+ statement="SELECT current_user()",
2823
+ warehouse_id=self._warehouse_id(),
2824
+ wait_timeout=self._wait_timeout(),
2825
+ )
2826
+ user = _extract_current_user(response)
2827
+ except Exception:
2828
+ user = "unknown"
2829
+ self._audit_user = user or "unknown"
2830
+ return self._audit_user
2831
+
2832
+ def _gate_rate_limit(self) -> None:
2833
+ """Throttle Statement Execution API calls to ``statement_rps_limit``.
2834
+
2835
+ S8: belt-and-braces against self-DoS. The Databricks SDK
2836
+ already retries 429s, but a tight ``kiri apply`` loop on a
2837
+ small warehouse can produce sustained 429-storms that waste
2838
+ the retry budget on every statement. This token-bucket
2839
+ sleeps the *next* call so the gap between calls honours the
2840
+ configured rate. Off by default (no profile knob set), so
2841
+ existing setups see no behaviour change.
2842
+ """
2843
+ if self._rps_limit is None:
2844
+ return
2845
+ min_interval = 1.0 / self._rps_limit
2846
+ now = time.monotonic()
2847
+ wait = min_interval - (now - self._last_call_at)
2848
+ if wait > 0:
2849
+ time.sleep(wait)
2850
+ now = time.monotonic()
2851
+ self._last_call_at = now
2852
+
2853
+ def _auth_mode(self) -> str:
2854
+ """Return the auth mode picked for the workspace client.
2855
+
2856
+ S9: makes the silent auth-priority decision observable.
2857
+ Operators can read ``adapter._auth_mode()`` (or set
2858
+ ``KIRIMANA_DATABRICKS_AUTH_MODE`` to assert) without reading
2859
+ the SDK's auth chain implementation.
2860
+ """
2861
+ if self.profile.get("client_id") and self.profile.get("client_secret"):
2862
+ return "oauth_m2m"
2863
+ if os.environ.get("DATABRICKS_CLIENT_ID") and os.environ.get("DATABRICKS_CLIENT_SECRET"):
2864
+ return "oauth_m2m"
2865
+ if self.profile.get("token") or os.environ.get("DATABRICKS_TOKEN"):
2866
+ return "pat"
2867
+ return "default_chain"
2868
+
2869
+ @staticmethod
2870
+ def _is_transient(exc: Exception) -> bool:
2871
+ """Classify transient failures by SDK exception type, with
2872
+ message-based fallback for transport-layer errors.
2873
+
2874
+ S5: the SDK exposes typed exceptions in
2875
+ ``databricks.sdk.errors`` that map to specific HTTP /
2876
+ platform conditions. Catching the type is robust to SDK
2877
+ message changes; the legacy string match was brittle as the
2878
+ SDK kept evolving its error wrappers.
2879
+
2880
+ Transport-level errors (``requests.exceptions.*``,
2881
+ ``urllib3``) don't go through the SDK error mapper; for those
2882
+ we keep a narrow message-based fallback.
2883
+ """
2884
+ try:
2885
+ from databricks.sdk.errors import (
2886
+ DeadlineExceeded,
2887
+ InternalError,
2888
+ OperationTimeout,
2889
+ RequestLimitExceeded,
2890
+ TemporarilyUnavailable,
2891
+ TooManyRequests,
2892
+ )
2893
+ except ImportError:
2894
+ # SDK not installed (unlikely on the Databricks adapter
2895
+ # path; keeps the classifier callable in tests that stub
2896
+ # the SDK away).
2897
+ transient_sdk_types: tuple[type[Exception], ...] = ()
2898
+ else:
2899
+ transient_sdk_types = (
2900
+ TooManyRequests,
2901
+ RequestLimitExceeded,
2902
+ TemporarilyUnavailable,
2903
+ DeadlineExceeded,
2904
+ OperationTimeout,
2905
+ InternalError,
2906
+ )
2907
+
2908
+ if transient_sdk_types and isinstance(exc, transient_sdk_types):
2909
+ return True
2910
+
2911
+ # Transport-layer fallback. The SDK doesn't wrap connection
2912
+ # resets / DNS timeouts / TLS hiccups; those raise from
2913
+ # ``urllib3`` / ``requests`` directly. Narrow on purpose — the
2914
+ # safety net for "request never reached the SDK error mapper,"
2915
+ # not a general string-matcher.
2916
+ msg = str(exc).lower()
2917
+ if "connection" in msg and ("reset" in msg or "aborted" in msg):
2918
+ return True
2919
+ return "timeout" in msg and ("connect" in msg or "read" in msg)
2920
+
2921
+ def _scalar(self, sql: str) -> Any:
2922
+ rows = self._execute(sql)
2923
+ if not rows:
2924
+ return None
2925
+ return rows[0][0]
2926
+
2927
+ def _detect_stale_meta_columns(self, target_fq: str) -> list[str]:
2928
+ """Return ``_dca_*`` meta column names on ``target_fq`` if any survive.
2929
+
2930
+ Used by ``_ingest_landing_zone`` as a one-shot pre-MERGE guard
2931
+ (issue #366). Returns ``[]`` if the table doesn't exist yet
2932
+ (``CREATE TABLE IF NOT EXISTS`` runs immediately after this in
2933
+ ``_merge_one_file``) or if every meta column is already
2934
+ renamed.
2935
+ """
2936
+ try:
2937
+ rows = self._execute(f"DESCRIBE TABLE {target_fq}")
2938
+ except Exception:
2939
+ # Most commonly: TABLE_OR_VIEW_NOT_FOUND. CREATE TABLE IF
2940
+ # NOT EXISTS will run next; no stale schema to guard.
2941
+ return []
2942
+ return _stale_meta_columns_from_describe(rows)
2943
+
2944
+ def _resolve_fixture(self, source: Source, plan: IngestionPlan) -> Path | None:
2945
+ fixtures_dir = self.profile.get("fixtures_dir")
2946
+ if not fixtures_dir:
2947
+ return None
2948
+ return Path(fixtures_dir) / source.name / f"{plan.table.physical_name}.csv"
2949
+
2950
+ def _upload_to_volume(
2951
+ self, local_path: Path, volume: str, source_name: str, filename: str
2952
+ ) -> str:
2953
+ """Upload one local file to a UC Volume (legacy fixture path).
2954
+
2955
+ ADR 0049: the SDK doesn't expose a per-call User-Agent
2956
+ override on ``files.upload()``; instead we set
2957
+ ``Config.user_agent_extra`` once on the WorkspaceClient so
2958
+ every Files API call carries the active trace context
2959
+ in its outgoing User-Agent header. The Files API audit log
2960
+ captures ``user_agent`` verbatim — auditors recover the
2961
+ trace-id from there.
2962
+ """
2963
+ parts = volume.split(".")
2964
+ if len(parts) != 3:
2965
+ raise ValueError(f"ingest_volume must be catalog.schema.volume (got {volume!r})")
2966
+ remote = f"/Volumes/{parts[0]}/{parts[1]}/{parts[2]}/{source_name}/{filename}"
2967
+ ws = self._workspace()
2968
+ # Per-upload trace stamp on the SDK's user-agent. SDK reads
2969
+ # this attribute on every outgoing request, so updating it
2970
+ # here propagates to the very next ``upload`` call without
2971
+ # reconstructing the client. Restored to the prior value on
2972
+ # function exit so concurrent callers don't see leaked state.
2973
+ prior_ua_extra = getattr(ws.config, "user_agent_extra", "")
2974
+ ws.config.user_agent_extra = current_trace().user_agent()
2975
+ try:
2976
+ with local_path.open("rb") as f:
2977
+ ws.files.upload(remote, f, overwrite=True)
2978
+ finally:
2979
+ ws.config.user_agent_extra = prior_ua_extra
2980
+ return remote