mostlyright-data 0.9.0__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.
Files changed (314) hide show
  1. mostlyright/data_harness/__init__.py +158 -0
  2. mostlyright/data_harness/acquisition/__init__.py +55 -0
  3. mostlyright/data_harness/acquisition/http.py +2773 -0
  4. mostlyright/data_harness/acquisition/parsing.py +809 -0
  5. mostlyright/data_harness/acquisition/ranges.py +495 -0
  6. mostlyright/data_harness/acquisition/result_download.py +360 -0
  7. mostlyright/data_harness/acquisition/retention_admission.py +248 -0
  8. mostlyright/data_harness/acquisition/sandbox.py +4888 -0
  9. mostlyright/data_harness/acquisition/url_policy.py +530 -0
  10. mostlyright/data_harness/agent_runtime.py +2743 -0
  11. mostlyright/data_harness/assets/logo-ink.svg +31 -0
  12. mostlyright/data_harness/backends/__init__.py +28 -0
  13. mostlyright/data_harness/backends/pandas_backend.py +350 -0
  14. mostlyright/data_harness/backends/polars_backend.py +366 -0
  15. mostlyright/data_harness/backends/protocol.py +124 -0
  16. mostlyright/data_harness/backends/reference.py +83 -0
  17. mostlyright/data_harness/backends/registry.py +55 -0
  18. mostlyright/data_harness/backends/restrictions.py +126 -0
  19. mostlyright/data_harness/canonical.py +333 -0
  20. mostlyright/data_harness/catalog_job.py +625 -0
  21. mostlyright/data_harness/cli.py +5398 -0
  22. mostlyright/data_harness/contracts.py +53 -0
  23. mostlyright/data_harness/coordinator.py +1307 -0
  24. mostlyright/data_harness/deploy.py +924 -0
  25. mostlyright/data_harness/deploy_target.py +312 -0
  26. mostlyright/data_harness/deployment_evidence.py +1067 -0
  27. mostlyright/data_harness/event_presentation.py +576 -0
  28. mostlyright/data_harness/events.py +2152 -0
  29. mostlyright/data_harness/fast_delimited.py +239 -0
  30. mostlyright/data_harness/fleet.py +237 -0
  31. mostlyright/data_harness/formats.py +236 -0
  32. mostlyright/data_harness/governors.py +1163 -0
  33. mostlyright/data_harness/hosted_bootstrap.py +972 -0
  34. mostlyright/data_harness/hosted_crawler.py +1115 -0
  35. mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
  36. mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
  37. mostlyright/data_harness/hosted_crawler_job.py +1277 -0
  38. mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
  39. mostlyright/data_harness/hosted_dataset.py +1500 -0
  40. mostlyright/data_harness/hosted_deploy.py +3037 -0
  41. mostlyright/data_harness/hosted_handoff.py +62 -0
  42. mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
  43. mostlyright/data_harness/hosted_ingestion_job.py +356 -0
  44. mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
  45. mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
  46. mostlyright/data_harness/hosted_session_worker.py +3554 -0
  47. mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
  48. mostlyright/data_harness/hosted_worker.py +6784 -0
  49. mostlyright/data_harness/ingestion/__init__.py +56 -0
  50. mostlyright/data_harness/ingestion/contracts.py +461 -0
  51. mostlyright/data_harness/ingestion/faults.py +42 -0
  52. mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
  53. mostlyright/data_harness/ingestion/spool.py +130 -0
  54. mostlyright/data_harness/ingestion/store.py +885 -0
  55. mostlyright/data_harness/key_seam.py +434 -0
  56. mostlyright/data_harness/linux_process_boundary.py +262 -0
  57. mostlyright/data_harness/local_contracts.py +2880 -0
  58. mostlyright/data_harness/local_search/__init__.py +5 -0
  59. mostlyright/data_harness/local_search/build_index.py +1087 -0
  60. mostlyright/data_harness/local_search/contracts.py +920 -0
  61. mostlyright/data_harness/local_search/query_trace.py +266 -0
  62. mostlyright/data_harness/local_search/retrieval.py +700 -0
  63. mostlyright/data_harness/local_search/sealed.py +474 -0
  64. mostlyright/data_harness/local_search/service.py +784 -0
  65. mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
  66. mostlyright/data_harness/nbrender/__init__.py +12 -0
  67. mostlyright/data_harness/nbrender/chrome.py +359 -0
  68. mostlyright/data_harness/nbrender/code_body.py +266 -0
  69. mostlyright/data_harness/nbrender/document.py +407 -0
  70. mostlyright/data_harness/nbrender/frame.py +275 -0
  71. mostlyright/data_harness/nbrender/interactive.py +337 -0
  72. mostlyright/data_harness/nbrender/markdown_body.py +477 -0
  73. mostlyright/data_harness/nbrender/mr_components.py +134 -0
  74. mostlyright/data_harness/nbrender/outputs_data.py +595 -0
  75. mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
  76. mostlyright/data_harness/nbrender/outputs_source.py +260 -0
  77. mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
  78. mostlyright/data_harness/nbrender/outputs_text.py +400 -0
  79. mostlyright/data_harness/nbrender/parse.py +394 -0
  80. mostlyright/data_harness/nbrender/status.py +40 -0
  81. mostlyright/data_harness/nbrender/tokens.py +1295 -0
  82. mostlyright/data_harness/notebook.py +1710 -0
  83. mostlyright/data_harness/offline.py +2049 -0
  84. mostlyright/data_harness/operation_registry.py +1007 -0
  85. mostlyright/data_harness/operator_setup.py +239 -0
  86. mostlyright/data_harness/pipeline.py +6428 -0
  87. mostlyright/data_harness/plan_graph.py +2026 -0
  88. mostlyright/data_harness/preparation/__init__.py +104 -0
  89. mostlyright/data_harness/preparation/contracts.py +1017 -0
  90. mostlyright/data_harness/preparation/engine.py +221 -0
  91. mostlyright/data_harness/preparation/errors.py +14 -0
  92. mostlyright/data_harness/preparation/gates.py +751 -0
  93. mostlyright/data_harness/preparation/joins.py +574 -0
  94. mostlyright/data_harness/preparation/profile.py +384 -0
  95. mostlyright/data_harness/preparation/table.py +217 -0
  96. mostlyright/data_harness/preparation/transforms.py +568 -0
  97. mostlyright/data_harness/progress_events.py +534 -0
  98. mostlyright/data_harness/readers/__init__.py +46 -0
  99. mostlyright/data_harness/readers/containers.py +963 -0
  100. mostlyright/data_harness/readers/contracts.py +542 -0
  101. mostlyright/data_harness/readers/delimited.py +257 -0
  102. mostlyright/data_harness/readers/grib2/__init__.py +33 -0
  103. mostlyright/data_harness/readers/grib2/admission.py +722 -0
  104. mostlyright/data_harness/readers/grib2/decode.py +1009 -0
  105. mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
  106. mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
  107. mostlyright/data_harness/readers/json_tabular.py +485 -0
  108. mostlyright/data_harness/readers/registry.py +514 -0
  109. mostlyright/data_harness/readers/samples/README.md +110 -0
  110. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
  111. mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
  112. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
  113. mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
  114. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
  115. mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
  116. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
  117. mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
  118. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
  119. mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
  120. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  121. mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
  122. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
  123. mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
  124. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
  125. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
  126. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
  127. mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
  128. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
  129. mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
  130. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
  131. mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
  132. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
  133. mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
  134. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
  135. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
  136. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  137. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  138. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
  139. mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
  140. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
  141. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
  142. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
  143. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
  144. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  145. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
  146. mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
  147. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
  148. mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
  149. mostlyright/data_harness/readers/samples.py +582 -0
  150. mostlyright/data_harness/readers/spreadsheet.py +803 -0
  151. mostlyright/data_harness/readers/tabular.py +510 -0
  152. mostlyright/data_harness/recipe.py +5321 -0
  153. mostlyright/data_harness/repair/__init__.py +78 -0
  154. mostlyright/data_harness/repair/adapters.py +274 -0
  155. mostlyright/data_harness/repair/contracts.py +872 -0
  156. mostlyright/data_harness/repair/coordinator.py +1099 -0
  157. mostlyright/data_harness/repair/errors.py +16 -0
  158. mostlyright/data_harness/review.py +2533 -0
  159. mostlyright/data_harness/rowset.py +283 -0
  160. mostlyright/data_harness/serving.py +1975 -0
  161. mostlyright/data_harness/serving_edge.py +590 -0
  162. mostlyright/data_harness/serving_http.py +1031 -0
  163. mostlyright/data_harness/session_probes.py +759 -0
  164. mostlyright/data_harness/signing.py +101 -0
  165. mostlyright/data_harness/source_discovery.py +898 -0
  166. mostlyright/data_harness/sources/__init__.py +209 -0
  167. mostlyright/data_harness/sources/_adapter_steps.py +213 -0
  168. mostlyright/data_harness/sources/adapters.py +1214 -0
  169. mostlyright/data_harness/sources/cadence.py +1428 -0
  170. mostlyright/data_harness/sources/cadence_emission.py +453 -0
  171. mostlyright/data_harness/sources/cadence_history.py +546 -0
  172. mostlyright/data_harness/sources/catalog/__init__.py +17 -0
  173. mostlyright/data_harness/sources/catalog/admission.py +477 -0
  174. mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
  175. mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
  176. mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
  177. mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
  178. mostlyright/data_harness/sources/catalog/channel.py +523 -0
  179. mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
  180. mostlyright/data_harness/sources/catalog/contracts.py +825 -0
  181. mostlyright/data_harness/sources/catalog/coverage.py +137 -0
  182. mostlyright/data_harness/sources/catalog/delta.py +1340 -0
  183. mostlyright/data_harness/sources/catalog/embedding.py +532 -0
  184. mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
  185. mostlyright/data_harness/sources/catalog/fill.py +3889 -0
  186. mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
  187. mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
  188. mostlyright/data_harness/sources/catalog/gating.py +374 -0
  189. mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
  190. mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
  191. mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
  192. mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
  193. mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
  194. mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
  195. mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
  196. mostlyright/data_harness/sources/catalog/health.py +447 -0
  197. mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
  198. mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
  199. mostlyright/data_harness/sources/catalog/neural.py +1618 -0
  200. mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
  201. mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
  202. mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
  203. mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
  204. mostlyright/data_harness/sources/catalog/recommend.py +171 -0
  205. mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
  206. mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
  207. mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
  208. mostlyright/data_harness/sources/catalog/sealed.py +560 -0
  209. mostlyright/data_harness/sources/catalog/search.py +230 -0
  210. mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
  211. mostlyright/data_harness/sources/catalog/update.py +891 -0
  212. mostlyright/data_harness/sources/collections.py +815 -0
  213. mostlyright/data_harness/sources/contracts.py +2223 -0
  214. mostlyright/data_harness/sources/deletion.py +761 -0
  215. mostlyright/data_harness/sources/fitness.py +162 -0
  216. mostlyright/data_harness/sources/governance.py +163 -0
  217. mostlyright/data_harness/sources/hosted.py +173 -0
  218. mostlyright/data_harness/sources/integration.py +218 -0
  219. mostlyright/data_harness/sources/range_reader.py +418 -0
  220. mostlyright/data_harness/sources/registry.py +514 -0
  221. mostlyright/data_harness/sources/rights_rule.py +59 -0
  222. mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
  223. mostlyright/data_harness/sources/sports.py +521 -0
  224. mostlyright/data_harness/sources/stream.py +524 -0
  225. mostlyright/data_harness/sources/stream_connector.py +418 -0
  226. mostlyright/data_harness/sources/stream_recorder.py +1404 -0
  227. mostlyright/data_harness/studio_boundary.py +2019 -0
  228. mostlyright/data_harness/thin/__init__.py +37 -0
  229. mostlyright/data_harness/thin/acquire.py +1137 -0
  230. mostlyright/data_harness/thin/acquire_cancel.py +579 -0
  231. mostlyright/data_harness/thin/approvals.py +617 -0
  232. mostlyright/data_harness/thin/commands.py +406 -0
  233. mostlyright/data_harness/thin/download.py +194 -0
  234. mostlyright/data_harness/thin/narrative.py +589 -0
  235. mostlyright/data_harness/thin/parity.py +1070 -0
  236. mostlyright/data_harness/thin/propose.py +2759 -0
  237. mostlyright/data_harness/thin/research.py +1663 -0
  238. mostlyright/data_harness/thin/router.py +924 -0
  239. mostlyright/data_harness/thin/runs.py +519 -0
  240. mostlyright/data_harness/thin/session.py +281 -0
  241. mostlyright/data_harness/thin/stream.py +501 -0
  242. mostlyright/data_harness/thin/transport.py +187 -0
  243. mostlyright/data_harness/thin/vocabulary.py +368 -0
  244. mostlyright/data_harness/thin/workers.py +164 -0
  245. mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
  246. mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
  247. mostlyright/data_harness/unit_flow.py +927 -0
  248. mostlyright/data_harness/units.py +572 -0
  249. mostlyright/data_harness/ux/__init__.py +9 -0
  250. mostlyright/data_harness/ux/approve.py +485 -0
  251. mostlyright/data_harness/ux/author_yaml.py +597 -0
  252. mostlyright/data_harness/ux/cloud_auth.py +447 -0
  253. mostlyright/data_harness/ux/commands/__init__.py +260 -0
  254. mostlyright/data_harness/ux/commands/approve.py +136 -0
  255. mostlyright/data_harness/ux/commands/auth.py +744 -0
  256. mostlyright/data_harness/ux/commands/author.py +79 -0
  257. mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
  258. mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
  259. mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
  260. mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
  261. mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
  262. mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
  263. mostlyright/data_harness/ux/commands/deploy.py +134 -0
  264. mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
  265. mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
  266. mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
  267. mostlyright/data_harness/ux/commands/diff.py +74 -0
  268. mostlyright/data_harness/ux/commands/index.py +84 -0
  269. mostlyright/data_harness/ux/commands/inventory.py +47 -0
  270. mostlyright/data_harness/ux/commands/list_builds.py +143 -0
  271. mostlyright/data_harness/ux/commands/login.py +63 -0
  272. mostlyright/data_harness/ux/commands/peek.py +236 -0
  273. mostlyright/data_harness/ux/commands/plan_check.py +90 -0
  274. mostlyright/data_harness/ux/commands/preflight.py +97 -0
  275. mostlyright/data_harness/ux/commands/record.py +107 -0
  276. mostlyright/data_harness/ux/commands/review_setup.py +47 -0
  277. mostlyright/data_harness/ux/commands/search.py +440 -0
  278. mostlyright/data_harness/ux/commands/show.py +61 -0
  279. mostlyright/data_harness/ux/commands/whoami.py +37 -0
  280. mostlyright/data_harness/ux/credential_native.py +551 -0
  281. mostlyright/data_harness/ux/credential_store.py +1055 -0
  282. mostlyright/data_harness/ux/credentials.py +631 -0
  283. mostlyright/data_harness/ux/diffing.py +444 -0
  284. mostlyright/data_harness/ux/headline.py +671 -0
  285. mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
  286. mostlyright/data_harness/ux/hosted_run_status.py +619 -0
  287. mostlyright/data_harness/ux/inventory.py +427 -0
  288. mostlyright/data_harness/ux/local_review.py +375 -0
  289. mostlyright/data_harness/ux/login.py +691 -0
  290. mostlyright/data_harness/ux/path_kind.py +147 -0
  291. mostlyright/data_harness/ux/peek.py +1000 -0
  292. mostlyright/data_harness/ux/plain_file.py +178 -0
  293. mostlyright/data_harness/ux/plan_check.py +311 -0
  294. mostlyright/data_harness/ux/preflight.py +918 -0
  295. mostlyright/data_harness/ux/readers.py +1124 -0
  296. mostlyright/data_harness/ux/remediation.py +2195 -0
  297. mostlyright/data_harness/ux/render.py +657 -0
  298. mostlyright/data_harness/ux/workload.py +1077 -0
  299. mostlyright/data_harness/viewer.py +3713 -0
  300. mostlyright/data_harness/visual_run/__init__.py +83 -0
  301. mostlyright/data_harness/visual_run/authoring.py +235 -0
  302. mostlyright/data_harness/visual_run/contracts.py +673 -0
  303. mostlyright/data_harness/visual_run/materialize.py +486 -0
  304. mostlyright/data_harness/visual_run/observations.py +874 -0
  305. mostlyright/data_harness/visual_run/query.py +259 -0
  306. mostlyright/data_harness/visual_run/reducer.py +280 -0
  307. mostlyright/data_harness/visual_run/sdk.py +892 -0
  308. mostlyright/data_harness/visual_run/store.py +584 -0
  309. mostlyright/data_harness/visual_run/transport.py +239 -0
  310. mostlyright/data_harness/watch.py +2999 -0
  311. mostlyright_data-0.9.0.dist-info/METADATA +607 -0
  312. mostlyright_data-0.9.0.dist-info/RECORD +314 -0
  313. mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
  314. mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
@@ -0,0 +1,1549 @@
1
+ """Durable provider identity and complete catalog version/absence history.
2
+
3
+ A provider record and a Harness catalog entry are different things, and the binding between them
4
+ has to survive every sweep, every rename, and every rights re-decision. This module is that
5
+ binding, plus the complete append-only history hanging off it.
6
+
7
+ Four separations are load-bearing.
8
+
9
+ *Identity versus prose.* ``(provider_id, provider_record_id)`` binds to exactly one catalog entry
10
+ id. The derived spelling is ``{provider_id}.{sha256(provider_record_id)}`` -- a full
11
+ collision-resistant digest, never a slug of a title -- and it is **persisted** with the record it
12
+ names and verified on read rather than recomputed from mutable prose. A declared title or landing
13
+ page may change on every sweep without moving identity by a single byte.
14
+
15
+ *Semantic versions versus evidence.* A version link is minted only when the semantic facts digest
16
+ changes. ``last_harvested_date`` and ``popularity`` remain outside that digest precisely
17
+ because Data.gov rewrites them on every re-harvest, so re-observing an unchanged record appends one
18
+ observation and mints nothing. Evidence-only observations never force a new semantic version.
19
+
20
+ *Append-only versus rewritable.* History only grows. A successor that drops, reorders, or rewrites
21
+ a retained event is a rollback; a successor that rewrites a version already published is a
22
+ post-publication mutation. Both are refused rather than reconciled, and so are a split identity, a
23
+ duplicate current head, and a version link whose predecessor was never observed.
24
+
25
+ *Bounded packs versus one big map.* A 548k-member JSON object is not a data structure this harness
26
+ will write -- ``canonical`` refuses more than 100,000 members, and nothing downstream could stream
27
+ it anyway. A generation is instead immutable content-addressed packs of at most
28
+ :data:`MAX_IDENTITY_PACK_RECORDS` records each, in ascending provider-record order, behind one small
29
+ manifest that carries only pack descriptors. Reading keeps at most one pack resident, whether the
30
+ caller streams the generation or looks one record up.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import contextlib
36
+ import os
37
+ import re
38
+ import secrets
39
+ import stat
40
+ from collections.abc import Iterable, Iterator, Sequence
41
+ from dataclasses import dataclass, replace
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ from mostlyright.data_harness.canonical import (
46
+ CanonicalJSONError,
47
+ canonical_json_bytes,
48
+ canonical_sha256,
49
+ parse_canonical_json,
50
+ sha256_bytes,
51
+ )
52
+ from mostlyright.data_harness.sources.catalog.bounded_io import (
53
+ BoundedReadFailure,
54
+ read_bounded_at,
55
+ read_bounded_path,
56
+ )
57
+ from mostlyright.data_harness.sources.contracts import SourceContractError, _CanonicalContract
58
+
59
+ IDENTITY_RECORD_SCHEMA = "harness-catalog-identity-history.v1"
60
+ IDENTITY_PACK_SCHEMA = "harness-catalog-identity-pack.v1"
61
+ IDENTITY_MANIFEST_SCHEMA = "harness-catalog-identity-manifest.v1"
62
+
63
+ IDENTITY_MANIFEST_FILENAME = "identity-manifest.json"
64
+ IDENTITY_PACKS_DIRNAME = "packs"
65
+
66
+ # One pack is the unit of residency: a reader holds at most one of these at a time.
67
+ MAX_IDENTITY_PACK_RECORDS = 1_000
68
+ MAX_IDENTITY_PACK_BYTES = 8 * 1024 * 1024
69
+ MAX_IDENTITY_MANIFEST_BYTES = 8 * 1024 * 1024
70
+
71
+ # One provider record's complete retained history. History is never deleted, so the bound is a
72
+ # refusal rather than an eviction: a record that would exceed it stops the sweep visibly.
73
+ MAX_IDENTITY_HISTORY_EVENTS = 4_096
74
+
75
+ # The whole-generation ceiling. The writer's collision check is the only structure whose size grows
76
+ # with the generation; at this bound it is tens of megabytes, well inside the plan's RSS budget.
77
+ MAX_IDENTITY_RECORDS = 2_000_000
78
+
79
+ # The preserved v1 path materializes an explicit provider-to-entry mapping in memory, so it stays
80
+ # deliberately small. Provider-scale generations use packs, never a mapping.
81
+ MAX_EXPLICIT_MAPPING = 10_000
82
+
83
+ MAX_PROVIDER_RECORD_ID = 512
84
+ MAX_ENTRY_ID = 256
85
+ MAX_ENTRY_VERSION = 10_000
86
+ MAX_VECTOR_REFERENCES = 16
87
+
88
+ IDENTITY_DERIVATIONS = ("provider_record_sha256", "explicit")
89
+ NON_ADMITTED_DISPOSITIONS = ("failed", "flagged", "skipped")
90
+
91
+ _IDENTIFIER = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
92
+ _SHA256 = re.compile(r"^[0-9a-f]{64}$")
93
+ _UTC_SECOND = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")
94
+
95
+ _OPEN_DIRECTORY = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
96
+ _OPEN_MEMBER = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
97
+
98
+
99
+ class CatalogIdentityRefused(SourceContractError):
100
+ """A stable refusal of an identity binding, a history transition, or a durable pack."""
101
+
102
+
103
+ # ------------------------------------------------------------------------------------------
104
+ # Identity
105
+ # ------------------------------------------------------------------------------------------
106
+
107
+
108
+ def derive_entry_id(provider_id: str, provider_record_id: str, *, prefix: str | None = None) -> str:
109
+ """Derive the one collision-resistant catalog entry id for a provider record."""
110
+
111
+ _identifier(provider_id, "identity.provider_id")
112
+ _text(provider_record_id, "identity.provider_record_id", maximum=MAX_PROVIDER_RECORD_ID)
113
+ stem = f"{provider_id}." if prefix is None else prefix
114
+ return f"{stem}{sha256_bytes(provider_record_id.encode('utf-8'))}"
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class ProviderIdentity(_CanonicalContract):
119
+ """One immutable binding of a provider record to one catalog entry id."""
120
+
121
+ provider_id: str
122
+ provider_record_id: str
123
+ entry_id: str
124
+ derivation: str = "provider_record_sha256"
125
+
126
+ def __post_init__(self) -> None:
127
+ _identifier(self.provider_id, "identity.provider_id")
128
+ _text(
129
+ self.provider_record_id, "identity.provider_record_id", maximum=MAX_PROVIDER_RECORD_ID
130
+ )
131
+ _entry_id(self.entry_id, "identity.entry_id")
132
+ if self.derivation not in IDENTITY_DERIVATIONS:
133
+ raise CatalogIdentityRefused(
134
+ "IDENTITY_CONTRACT",
135
+ "identity.derivation",
136
+ f"must be one of {list(IDENTITY_DERIVATIONS)}",
137
+ )
138
+ if self.derivation == "provider_record_sha256" and self.entry_id != derive_entry_id(
139
+ self.provider_id, self.provider_record_id
140
+ ):
141
+ raise CatalogIdentityRefused(
142
+ "IDENTITY_DERIVATION",
143
+ "identity.entry_id",
144
+ "a persisted derived entry id must reproduce its own derivation exactly",
145
+ )
146
+
147
+ @property
148
+ def key(self) -> tuple[str, str]:
149
+ return (self.provider_id, self.provider_record_id)
150
+
151
+ @property
152
+ def sort_key(self) -> bytes:
153
+ return self.provider_record_id.encode("utf-8")
154
+
155
+ def to_dict(self) -> dict[str, str]:
156
+ return {
157
+ "provider_id": self.provider_id,
158
+ "provider_record_id": self.provider_record_id,
159
+ "entry_id": self.entry_id,
160
+ "derivation": self.derivation,
161
+ }
162
+
163
+
164
+ # ------------------------------------------------------------------------------------------
165
+ # History events
166
+ # ------------------------------------------------------------------------------------------
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class SemanticVersionLink(_CanonicalContract):
171
+ """One semantic version of an entry, linked to the exact state it superseded."""
172
+
173
+ entry_version: int
174
+ semantic_facts_digest: str
175
+ previous_semantic_digest: str | None
176
+ observed_at: str
177
+ sweep_sha256: str
178
+ provider_record_sha256: str
179
+ vector_sha256s: tuple[str, ...] = ()
180
+ published: bool = False
181
+
182
+ def __post_init__(self) -> None:
183
+ if type(self.entry_version) is not int or not 1 <= self.entry_version <= MAX_ENTRY_VERSION:
184
+ raise CatalogIdentityRefused(
185
+ "IDENTITY_CONTRACT",
186
+ "version.entry_version",
187
+ f"must be an integer in [1, {MAX_ENTRY_VERSION}]",
188
+ )
189
+ _digest(self.semantic_facts_digest, "version.semantic_facts_digest")
190
+ _optional_digest(self.previous_semantic_digest, "version.previous_semantic_digest")
191
+ _utc_second(self.observed_at, "version.observed_at")
192
+ _digest(self.sweep_sha256, "version.sweep_sha256")
193
+ _digest(self.provider_record_sha256, "version.provider_record_sha256")
194
+ if type(self.published) is not bool:
195
+ raise CatalogIdentityRefused(
196
+ "IDENTITY_CONTRACT", "version.published", "must be a boolean"
197
+ )
198
+ if (
199
+ not isinstance(self.vector_sha256s, tuple)
200
+ or len(self.vector_sha256s) > MAX_VECTOR_REFERENCES
201
+ ):
202
+ raise CatalogIdentityRefused(
203
+ "IDENTITY_CONTRACT",
204
+ "version.vector_sha256s",
205
+ f"must be an immutable tuple of at most {MAX_VECTOR_REFERENCES} references",
206
+ )
207
+ for index, value in enumerate(self.vector_sha256s):
208
+ _digest(value, f"version.vector_sha256s[{index}]")
209
+ if (self.entry_version == 1) != (self.previous_semantic_digest is None):
210
+ raise CatalogIdentityRefused(
211
+ "IDENTITY_PREDECESSOR_MISSING",
212
+ "version.previous_semantic_digest",
213
+ "a first version has no predecessor and a successor must name one",
214
+ )
215
+
216
+ def to_dict(self) -> dict[str, Any]:
217
+ return {
218
+ "entry_version": self.entry_version,
219
+ "semantic_facts_digest": self.semantic_facts_digest,
220
+ "previous_semantic_digest": self.previous_semantic_digest,
221
+ "observed_at": self.observed_at,
222
+ "sweep_sha256": self.sweep_sha256,
223
+ "provider_record_sha256": self.provider_record_sha256,
224
+ "vector_sha256s": list(self.vector_sha256s),
225
+ "published": self.published,
226
+ }
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class ObservationEvidence(_CanonicalContract):
231
+ """One sweep that observed a record's semantic state; never a semantic fact itself."""
232
+
233
+ observed_at: str
234
+ semantic_facts_digest: str
235
+ sweep_sha256: str
236
+ response_evidence_sha256: str | None = None
237
+ page_evidence_sha256: str | None = None
238
+
239
+ def __post_init__(self) -> None:
240
+ _utc_second(self.observed_at, "observation.observed_at")
241
+ _digest(self.semantic_facts_digest, "observation.semantic_facts_digest")
242
+ _digest(self.sweep_sha256, "observation.sweep_sha256")
243
+ _optional_digest(self.response_evidence_sha256, "observation.response_evidence_sha256")
244
+ _optional_digest(self.page_evidence_sha256, "observation.page_evidence_sha256")
245
+
246
+ def to_dict(self) -> dict[str, Any]:
247
+ return {
248
+ "observed_at": self.observed_at,
249
+ "semantic_facts_digest": self.semantic_facts_digest,
250
+ "sweep_sha256": self.sweep_sha256,
251
+ "response_evidence_sha256": self.response_evidence_sha256,
252
+ "page_evidence_sha256": self.page_evidence_sha256,
253
+ }
254
+
255
+
256
+ @dataclass(frozen=True)
257
+ class AbsenceEvidence(_CanonicalContract):
258
+ """One complete sweep in which a previously known provider record was not present."""
259
+
260
+ observed_at: str
261
+ last_semantic_facts_digest: str
262
+ sweep_sha256: str
263
+
264
+ def __post_init__(self) -> None:
265
+ _utc_second(self.observed_at, "absence.observed_at")
266
+ _digest(self.last_semantic_facts_digest, "absence.last_semantic_facts_digest")
267
+ _digest(self.sweep_sha256, "absence.sweep_sha256")
268
+
269
+ def to_dict(self) -> dict[str, str]:
270
+ return {
271
+ "observed_at": self.observed_at,
272
+ "last_semantic_facts_digest": self.last_semantic_facts_digest,
273
+ "sweep_sha256": self.sweep_sha256,
274
+ }
275
+
276
+
277
+ @dataclass(frozen=True)
278
+ class DispositionEvidence(_CanonicalContract):
279
+ """One durable non-admitted disposition, so a skipped record never simply disappears."""
280
+
281
+ observed_at: str
282
+ disposition: str
283
+ reason_code: str
284
+ sweep_sha256: str
285
+ provider_record_sha256: str | None = None
286
+
287
+ def __post_init__(self) -> None:
288
+ _utc_second(self.observed_at, "disposition.observed_at")
289
+ if self.disposition not in NON_ADMITTED_DISPOSITIONS:
290
+ raise CatalogIdentityRefused(
291
+ "IDENTITY_CONTRACT",
292
+ "disposition.disposition",
293
+ f"must be one of {list(NON_ADMITTED_DISPOSITIONS)}",
294
+ )
295
+ _text(self.reason_code, "disposition.reason_code", maximum=128)
296
+ _digest(self.sweep_sha256, "disposition.sweep_sha256")
297
+ _optional_digest(self.provider_record_sha256, "disposition.provider_record_sha256")
298
+
299
+ def to_dict(self) -> dict[str, Any]:
300
+ return {
301
+ "observed_at": self.observed_at,
302
+ "disposition": self.disposition,
303
+ "reason_code": self.reason_code,
304
+ "sweep_sha256": self.sweep_sha256,
305
+ "provider_record_sha256": self.provider_record_sha256,
306
+ }
307
+
308
+
309
+ # ------------------------------------------------------------------------------------------
310
+ # One provider record's complete history
311
+ # ------------------------------------------------------------------------------------------
312
+
313
+
314
+ @dataclass(frozen=True)
315
+ class IdentityHistory(_CanonicalContract):
316
+ """One provider record's immutable identity plus its complete append-only history."""
317
+
318
+ identity: ProviderIdentity
319
+ policy_sha256: str
320
+ harvester_coordinate: str
321
+ versions: tuple[SemanticVersionLink, ...] = ()
322
+ observations: tuple[ObservationEvidence, ...] = ()
323
+ absences: tuple[AbsenceEvidence, ...] = ()
324
+ dispositions: tuple[DispositionEvidence, ...] = ()
325
+ schema_version: str = IDENTITY_RECORD_SCHEMA
326
+
327
+ def __post_init__(self) -> None:
328
+ if not isinstance(self.identity, ProviderIdentity):
329
+ raise CatalogIdentityRefused(
330
+ "IDENTITY_CONTRACT", "history.identity", "must be a ProviderIdentity"
331
+ )
332
+ if self.schema_version != IDENTITY_RECORD_SCHEMA:
333
+ raise CatalogIdentityRefused(
334
+ "IDENTITY_CONTRACT", "history.schema_version", "unsupported record schema"
335
+ )
336
+ _digest(self.policy_sha256, "history.policy_sha256")
337
+ _text(self.harvester_coordinate, "history.harvester_coordinate", maximum=192)
338
+ for name, expected in (
339
+ ("versions", SemanticVersionLink),
340
+ ("observations", ObservationEvidence),
341
+ ("absences", AbsenceEvidence),
342
+ ("dispositions", DispositionEvidence),
343
+ ):
344
+ events = getattr(self, name)
345
+ if not isinstance(events, tuple) or any(
346
+ not isinstance(item, expected) for item in events
347
+ ):
348
+ raise CatalogIdentityRefused(
349
+ "IDENTITY_CONTRACT",
350
+ f"history.{name}",
351
+ f"must be an immutable tuple of {expected.__name__}",
352
+ )
353
+ if self.event_count > MAX_IDENTITY_HISTORY_EVENTS:
354
+ raise CatalogIdentityRefused(
355
+ "IDENTITY_HISTORY_LIMIT",
356
+ "history",
357
+ f"one record retains at most {MAX_IDENTITY_HISTORY_EVENTS} events",
358
+ )
359
+ if not self.versions and not self.dispositions:
360
+ raise CatalogIdentityRefused(
361
+ "IDENTITY_CONTRACT",
362
+ "history.versions",
363
+ "a record with no semantic version must carry its non-admitted disposition",
364
+ )
365
+ self._validate_versions()
366
+ self._validate_evidence()
367
+
368
+ def _validate_versions(self) -> None:
369
+ seen: set[str] = set()
370
+ for index, link in enumerate(self.versions):
371
+ if link.entry_version != index + 1:
372
+ raise CatalogIdentityRefused(
373
+ "IDENTITY_HISTORY_ORDER",
374
+ f"history.versions[{index}].entry_version",
375
+ "version links must be contiguous and ascending from one",
376
+ )
377
+ expected = None if index == 0 else self.versions[index - 1].semantic_facts_digest
378
+ if link.previous_semantic_digest != expected:
379
+ raise CatalogIdentityRefused(
380
+ "IDENTITY_PREDECESSOR_MISSING",
381
+ f"history.versions[{index}].previous_semantic_digest",
382
+ "must name the exact semantic state this version superseded",
383
+ )
384
+ if link.semantic_facts_digest in seen:
385
+ raise CatalogIdentityRefused(
386
+ "IDENTITY_HISTORY_ROLLBACK",
387
+ f"history.versions[{index}].semantic_facts_digest",
388
+ "a superseded semantic state may not be re-presented as a new version",
389
+ )
390
+ seen.add(link.semantic_facts_digest)
391
+
392
+ def _validate_evidence(self) -> None:
393
+ seen: set[bytes] = set()
394
+ for name in ("versions", "observations", "absences", "dispositions"):
395
+ for item in getattr(self, name):
396
+ raw = canonical_json_bytes({"stream": name, "event": item.to_dict()})
397
+ if raw in seen:
398
+ raise CatalogIdentityRefused(
399
+ "IDENTITY_HISTORY_ORDER", f"history.{name}", "events must be unique"
400
+ )
401
+ seen.add(raw)
402
+ for name in ("observations", "absences", "dispositions"):
403
+ observed = [item.observed_at for item in getattr(self, name)]
404
+ if observed != sorted(observed):
405
+ raise CatalogIdentityRefused(
406
+ "IDENTITY_HISTORY_ORDER",
407
+ f"history.{name}",
408
+ "evidence must be appended in non-decreasing observation order",
409
+ )
410
+
411
+ @property
412
+ def key(self) -> tuple[str, str]:
413
+ return self.identity.key
414
+
415
+ @property
416
+ def sort_key(self) -> bytes:
417
+ return self.identity.sort_key
418
+
419
+ @property
420
+ def head(self) -> SemanticVersionLink | None:
421
+ return self.versions[-1] if self.versions else None
422
+
423
+ @property
424
+ def head_semantic_digest(self) -> str | None:
425
+ return None if self.head is None else self.head.semantic_facts_digest
426
+
427
+ @property
428
+ def event_count(self) -> int:
429
+ return (
430
+ len(self.versions)
431
+ + len(self.observations)
432
+ + len(self.absences)
433
+ + len(self.dispositions)
434
+ )
435
+
436
+ @property
437
+ def last_observed_at(self) -> str | None:
438
+ stamps = [
439
+ item.observed_at
440
+ for stream in (self.observations, self.absences, self.dispositions)
441
+ for item in stream
442
+ ]
443
+ return max(stamps) if stamps else None
444
+
445
+ def to_dict(self) -> dict[str, Any]:
446
+ return {
447
+ "schema_version": self.schema_version,
448
+ "identity": self.identity.to_dict(),
449
+ "policy_sha256": self.policy_sha256,
450
+ "harvester_coordinate": self.harvester_coordinate,
451
+ "versions": [item.to_dict() for item in self.versions],
452
+ "observations": [item.to_dict() for item in self.observations],
453
+ "absences": [item.to_dict() for item in self.absences],
454
+ "dispositions": [item.to_dict() for item in self.dispositions],
455
+ }
456
+
457
+
458
+ # ------------------------------------------------------------------------------------------
459
+ # Append-only transitions
460
+ # ------------------------------------------------------------------------------------------
461
+
462
+
463
+ def advance_identity_history(
464
+ previous: IdentityHistory | None,
465
+ *,
466
+ identity: ProviderIdentity,
467
+ policy_sha256: str,
468
+ harvester_coordinate: str,
469
+ semantic_facts_digest: str,
470
+ provider_record_sha256: str,
471
+ observation: ObservationEvidence,
472
+ expected_previous_semantic_digest: str | None = None,
473
+ vector_sha256s: tuple[str, ...] = (),
474
+ ) -> IdentityHistory:
475
+ """Observe one provider record, minting a version only when the semantic facts changed."""
476
+
477
+ if not isinstance(observation, ObservationEvidence):
478
+ raise CatalogIdentityRefused(
479
+ "IDENTITY_CONTRACT", "observation", "must be an ObservationEvidence"
480
+ )
481
+ _digest(semantic_facts_digest, "history.semantic_facts_digest")
482
+ if observation.semantic_facts_digest != semantic_facts_digest:
483
+ raise CatalogIdentityRefused(
484
+ "IDENTITY_CONTRACT",
485
+ "observation.semantic_facts_digest",
486
+ "an observation must cite the exact semantic state it observed",
487
+ )
488
+ if previous is None:
489
+ if expected_previous_semantic_digest is not None:
490
+ raise CatalogIdentityRefused(
491
+ "IDENTITY_PREDECESSOR_MISSING",
492
+ "history.previous",
493
+ "no retained history carries the named predecessor state",
494
+ )
495
+ return IdentityHistory(
496
+ identity=identity,
497
+ policy_sha256=policy_sha256,
498
+ harvester_coordinate=harvester_coordinate,
499
+ versions=(
500
+ SemanticVersionLink(
501
+ entry_version=1,
502
+ semantic_facts_digest=semantic_facts_digest,
503
+ previous_semantic_digest=None,
504
+ observed_at=observation.observed_at,
505
+ sweep_sha256=observation.sweep_sha256,
506
+ provider_record_sha256=provider_record_sha256,
507
+ vector_sha256s=vector_sha256s,
508
+ ),
509
+ ),
510
+ observations=(observation,),
511
+ )
512
+
513
+ _require_history(previous)
514
+ if previous.identity != identity:
515
+ raise CatalogIdentityRefused(
516
+ "IDENTITY_SPLIT",
517
+ "history.identity",
518
+ "one provider record keeps exactly one persisted catalog identity",
519
+ )
520
+ head = previous.head
521
+ if head is not None and semantic_facts_digest == head.semantic_facts_digest:
522
+ if (
523
+ expected_previous_semantic_digest is not None
524
+ and expected_previous_semantic_digest != head.previous_semantic_digest
525
+ ):
526
+ raise CatalogIdentityRefused(
527
+ "IDENTITY_PREDECESSOR_MISSING",
528
+ "history.previous_semantic_digest",
529
+ "the retained head does not carry the named predecessor state",
530
+ )
531
+ return replace(previous, observations=(*previous.observations, observation))
532
+
533
+ if expected_previous_semantic_digest is not None and expected_previous_semantic_digest != (
534
+ None if head is None else head.semantic_facts_digest
535
+ ):
536
+ raise CatalogIdentityRefused(
537
+ "IDENTITY_PREDECESSOR_MISSING",
538
+ "history.previous_semantic_digest",
539
+ "the retained head does not carry the named predecessor state",
540
+ )
541
+ if any(link.semantic_facts_digest == semantic_facts_digest for link in previous.versions):
542
+ raise CatalogIdentityRefused(
543
+ "IDENTITY_HISTORY_ROLLBACK",
544
+ "history.versions",
545
+ "a superseded semantic state may not be re-presented as a new version",
546
+ )
547
+ link = SemanticVersionLink(
548
+ entry_version=1 if head is None else head.entry_version + 1,
549
+ semantic_facts_digest=semantic_facts_digest,
550
+ previous_semantic_digest=None if head is None else head.semantic_facts_digest,
551
+ observed_at=observation.observed_at,
552
+ sweep_sha256=observation.sweep_sha256,
553
+ provider_record_sha256=provider_record_sha256,
554
+ vector_sha256s=vector_sha256s,
555
+ )
556
+ return replace(
557
+ previous,
558
+ versions=(*previous.versions, link),
559
+ observations=(*previous.observations, observation),
560
+ )
561
+
562
+
563
+ def observe_identity_absence(
564
+ previous: IdentityHistory, *, observed_at: str, sweep_sha256: str
565
+ ) -> IdentityHistory:
566
+ """Append one absence observation without deleting or superseding any retained state."""
567
+
568
+ _require_history(previous)
569
+ head = previous.head
570
+ if head is None:
571
+ raise CatalogIdentityRefused(
572
+ "IDENTITY_CONTRACT",
573
+ "history.absences",
574
+ "absence is only observable for a record that once had a semantic state",
575
+ )
576
+ absence = AbsenceEvidence(
577
+ observed_at=observed_at,
578
+ last_semantic_facts_digest=head.semantic_facts_digest,
579
+ sweep_sha256=sweep_sha256,
580
+ )
581
+ return replace(previous, absences=(*previous.absences, absence))
582
+
583
+
584
+ def record_identity_disposition(
585
+ previous: IdentityHistory | None,
586
+ *,
587
+ identity: ProviderIdentity,
588
+ policy_sha256: str,
589
+ harvester_coordinate: str,
590
+ disposition: str,
591
+ reason_code: str,
592
+ observed_at: str,
593
+ sweep_sha256: str,
594
+ provider_record_sha256: str | None = None,
595
+ ) -> IdentityHistory:
596
+ """Append one durable non-admitted disposition; it never mints or moves a version."""
597
+
598
+ evidence = DispositionEvidence(
599
+ observed_at=observed_at,
600
+ disposition=disposition,
601
+ reason_code=reason_code,
602
+ sweep_sha256=sweep_sha256,
603
+ provider_record_sha256=provider_record_sha256,
604
+ )
605
+ if previous is None:
606
+ return IdentityHistory(
607
+ identity=identity,
608
+ policy_sha256=policy_sha256,
609
+ harvester_coordinate=harvester_coordinate,
610
+ dispositions=(evidence,),
611
+ )
612
+ _require_history(previous)
613
+ if previous.identity != identity:
614
+ raise CatalogIdentityRefused(
615
+ "IDENTITY_SPLIT",
616
+ "history.identity",
617
+ "one provider record keeps exactly one persisted catalog identity",
618
+ )
619
+ return replace(previous, dispositions=(*previous.dispositions, evidence))
620
+
621
+
622
+ def publish_identity_head(
623
+ history: IdentityHistory, *, vector_sha256s: tuple[str, ...] = ()
624
+ ) -> IdentityHistory:
625
+ """Mark the current head published, binding the sealed member references it was sealed with."""
626
+
627
+ _require_history(history)
628
+ head = history.head
629
+ if head is None:
630
+ raise CatalogIdentityRefused(
631
+ "IDENTITY_CONTRACT", "history.versions", "no semantic head to publish"
632
+ )
633
+ if head.published:
634
+ raise CatalogIdentityRefused(
635
+ "IDENTITY_PUBLISHED_MUTATION",
636
+ "history.versions",
637
+ "a published version is immutable and may not be published again",
638
+ )
639
+ published = replace(head, published=True, vector_sha256s=vector_sha256s)
640
+ return replace(history, versions=(*history.versions[:-1], published))
641
+
642
+
643
+ def verify_identity_successor(previous: IdentityHistory, successor: IdentityHistory) -> None:
644
+ """Refuse any successor that is not a strict append-only extension of its predecessor."""
645
+
646
+ _require_history(previous)
647
+ _require_history(successor)
648
+ if previous.key != successor.key:
649
+ raise CatalogIdentityRefused(
650
+ "IDENTITY_CONTRACT",
651
+ "history.identity",
652
+ "a successor must describe the same provider record",
653
+ )
654
+ if previous.identity.entry_id != successor.identity.entry_id:
655
+ raise CatalogIdentityRefused(
656
+ "IDENTITY_SPLIT",
657
+ "history.identity.entry_id",
658
+ "one provider record may not split across two catalog entry ids",
659
+ )
660
+ for index, link in enumerate(previous.versions):
661
+ if index >= len(successor.versions) or successor.versions[index] != link:
662
+ code = "IDENTITY_PUBLISHED_MUTATION" if link.published else "IDENTITY_HISTORY_ROLLBACK"
663
+ raise CatalogIdentityRefused(
664
+ code,
665
+ f"history.versions[{index}]",
666
+ "retained version links are immutable and may only be extended",
667
+ )
668
+ for name in ("observations", "absences", "dispositions"):
669
+ retained = getattr(previous, name)
670
+ offered = getattr(successor, name)
671
+ if len(offered) < len(retained) or offered[: len(retained)] != retained:
672
+ raise CatalogIdentityRefused(
673
+ "IDENTITY_HISTORY_ROLLBACK",
674
+ f"history.{name}",
675
+ "retained evidence is immutable and may only be extended",
676
+ )
677
+
678
+
679
+ # ------------------------------------------------------------------------------------------
680
+ # The preserved explicit mapping path for bounded v1 fills
681
+ # ------------------------------------------------------------------------------------------
682
+
683
+
684
+ @dataclass(frozen=True)
685
+ class ExplicitIdentityBinding:
686
+ """One caller-chosen provider-record-to-entry binding, as v1 fills always supplied it."""
687
+
688
+ provider_record_id: str
689
+ entry_id: str
690
+ semantic_facts_digest: str
691
+ provider_record_sha256: str
692
+
693
+
694
+ def identity_histories_from_explicit_mapping(
695
+ bindings: Sequence[ExplicitIdentityBinding],
696
+ *,
697
+ provider_id: str,
698
+ policy_sha256: str,
699
+ harvester_coordinate: str,
700
+ observed_at: str,
701
+ sweep_sha256: str,
702
+ maximum: int = MAX_EXPLICIT_MAPPING,
703
+ ) -> tuple[IdentityHistory, ...]:
704
+ """Build first-version histories from an explicit mapping, collision-checked and bounded."""
705
+
706
+ if not isinstance(bindings, Sequence) or isinstance(bindings, (str, bytes)):
707
+ raise CatalogIdentityRefused(
708
+ "IDENTITY_CONTRACT", "bindings", "must be a sequence of ExplicitIdentityBinding"
709
+ )
710
+ if len(bindings) > maximum:
711
+ raise CatalogIdentityRefused(
712
+ "IDENTITY_MAPPING_LIMIT",
713
+ "bindings",
714
+ f"the explicit mapping path carries at most {maximum} records",
715
+ )
716
+ histories: list[IdentityHistory] = []
717
+ by_record: set[str] = set()
718
+ by_entry: set[str] = set()
719
+ for binding in bindings:
720
+ if not isinstance(binding, ExplicitIdentityBinding):
721
+ raise CatalogIdentityRefused(
722
+ "IDENTITY_CONTRACT", "bindings[]", "must be an ExplicitIdentityBinding"
723
+ )
724
+ if binding.provider_record_id in by_record:
725
+ raise CatalogIdentityRefused(
726
+ "IDENTITY_SPLIT",
727
+ "bindings[]",
728
+ "one provider record may not be bound to two catalog entry ids",
729
+ )
730
+ if binding.entry_id in by_entry:
731
+ raise CatalogIdentityRefused(
732
+ "IDENTITY_COLLISION",
733
+ "bindings[]",
734
+ "one catalog entry id may not be bound to two provider records",
735
+ )
736
+ by_record.add(binding.provider_record_id)
737
+ by_entry.add(binding.entry_id)
738
+ identity = ProviderIdentity(
739
+ provider_id=provider_id,
740
+ provider_record_id=binding.provider_record_id,
741
+ entry_id=binding.entry_id,
742
+ derivation="explicit",
743
+ )
744
+ histories.append(
745
+ advance_identity_history(
746
+ None,
747
+ identity=identity,
748
+ policy_sha256=policy_sha256,
749
+ harvester_coordinate=harvester_coordinate,
750
+ semantic_facts_digest=binding.semantic_facts_digest,
751
+ provider_record_sha256=binding.provider_record_sha256,
752
+ observation=ObservationEvidence(
753
+ observed_at=observed_at,
754
+ semantic_facts_digest=binding.semantic_facts_digest,
755
+ sweep_sha256=sweep_sha256,
756
+ ),
757
+ )
758
+ )
759
+ return tuple(sorted(histories, key=lambda item: item.sort_key))
760
+
761
+
762
+ # ------------------------------------------------------------------------------------------
763
+ # Durable immutable packs behind one small manifest
764
+ # ------------------------------------------------------------------------------------------
765
+
766
+
767
+ class DurableDirectory:
768
+ """One locally confined output root whose members install atomically and read back exactly."""
769
+
770
+ def __init__(
771
+ self,
772
+ root: Path,
773
+ *,
774
+ member_dirname: str,
775
+ code_prefix: str,
776
+ refusal: type[SourceContractError] = CatalogIdentityRefused,
777
+ root_descriptor: int | None = None,
778
+ ) -> None:
779
+ self.root = Path(root)
780
+ self.bytes_written = 0
781
+ self._member_dirname = member_dirname
782
+ self._prefix = code_prefix
783
+ self._refusal = refusal
784
+ self._retained_root_descriptor = root_descriptor
785
+ self._root_fd = -1
786
+ self._members_fd = -1
787
+ self._owned: list[int] = []
788
+
789
+ def _refuse(self, code: str, path: str, detail: str) -> SourceContractError:
790
+ return self._refusal(f"{self._prefix}_{code}", path, detail)
791
+
792
+ @contextlib.contextmanager
793
+ def opened(self) -> Iterator[DurableDirectory]:
794
+ try:
795
+ if self._retained_root_descriptor is None:
796
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
797
+ self._root_fd = self._open_directory(self.root)
798
+ else:
799
+ self._root_fd = os.dup(self._retained_root_descriptor)
800
+ self._owned.append(self._root_fd)
801
+ self._require_private_directory(self._root_fd)
802
+ try:
803
+ os.mkdir(self._member_dirname, mode=0o700, dir_fd=self._root_fd)
804
+ except FileExistsError:
805
+ pass
806
+ self._members_fd = self._open_directory_at(self._root_fd, self._member_dirname)
807
+ except OSError as error:
808
+ self._release()
809
+ raise self._refuse("OUTPUT_IO", "output", "output root is unusable") from error
810
+ except SourceContractError:
811
+ self._release()
812
+ raise
813
+ try:
814
+ yield self
815
+ finally:
816
+ self._release()
817
+
818
+ def _release(self) -> None:
819
+ for descriptor in reversed(self._owned):
820
+ os.close(descriptor)
821
+ self._owned = []
822
+ self._root_fd = -1
823
+ self._members_fd = -1
824
+
825
+ def _open_directory(self, path: Path) -> int:
826
+ descriptor = os.open(path, _OPEN_DIRECTORY)
827
+ self._owned.append(descriptor)
828
+ self._require_private_directory(descriptor)
829
+ return descriptor
830
+
831
+ def _open_directory_at(self, parent_descriptor: int, name: str) -> int:
832
+ descriptor = os.open(name, _OPEN_DIRECTORY, dir_fd=parent_descriptor)
833
+ self._owned.append(descriptor)
834
+ self._require_private_directory(descriptor)
835
+ return descriptor
836
+
837
+ def _require_private_directory(self, descriptor: int) -> None:
838
+ info = os.fstat(descriptor)
839
+ if not stat.S_ISDIR(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077:
840
+ raise self._refuse(
841
+ "OUTPUT_PATH", "output", "output components must be private directories"
842
+ )
843
+
844
+ def read(self, name: str, *, maximum: int) -> bytes | None:
845
+ try:
846
+ return _read_regular_at(
847
+ self._root_fd, name, maximum=maximum, prefix=self._prefix, refusal=self._refusal
848
+ )
849
+ except FileNotFoundError:
850
+ return None
851
+
852
+ def write_member(self, payload: dict[str, Any], *, maximum: int) -> tuple[str, int]:
853
+ raw = canonical_json_bytes(payload)
854
+ if len(raw) > maximum:
855
+ raise self._refuse("MEMBER_LIMIT", "output.member", "member exceeds its byte bound")
856
+ digest = sha256_bytes(raw)
857
+ name = f"{digest}.json"
858
+ try:
859
+ existing = _read_regular_at(
860
+ self._members_fd, name, maximum=len(raw), prefix=self._prefix, refusal=self._refusal
861
+ )
862
+ except FileNotFoundError:
863
+ existing = None
864
+ except OSError as error:
865
+ raise self._refuse("OUTPUT_IO", "output.member", "member lookup failed") from error
866
+ if existing is not None:
867
+ if existing != raw:
868
+ raise self._refuse("OUTPUT_READBACK", "output.member", "digest path bytes differ")
869
+ return digest, len(raw)
870
+ temporary = f".publish.{os.getpid()}.{secrets.token_hex(16)}.tmp"
871
+ try:
872
+ _write_regular_at(
873
+ self._members_fd, temporary, raw, prefix=self._prefix, refusal=self._refusal
874
+ )
875
+ with contextlib.suppress(FileExistsError):
876
+ os.link(
877
+ temporary,
878
+ name,
879
+ src_dir_fd=self._members_fd,
880
+ dst_dir_fd=self._members_fd,
881
+ follow_symlinks=False,
882
+ )
883
+ except OSError as error:
884
+ raise self._refuse("OUTPUT_IO", "output.member", "member publication failed") from error
885
+ finally:
886
+ with contextlib.suppress(FileNotFoundError):
887
+ os.unlink(temporary, dir_fd=self._members_fd)
888
+ os.fsync(self._members_fd)
889
+ if (
890
+ _read_regular_at(
891
+ self._members_fd, name, maximum=len(raw), prefix=self._prefix, refusal=self._refusal
892
+ )
893
+ != raw
894
+ ):
895
+ raise self._refuse("OUTPUT_READBACK", "output.member", "member readback differs")
896
+ self.bytes_written += len(raw)
897
+ return digest, len(raw)
898
+
899
+ def publish(self, name: str, payload: dict[str, Any], *, maximum: int) -> str:
900
+ raw = canonical_json_bytes(payload)
901
+ if len(raw) > maximum:
902
+ raise self._refuse("MEMBER_LIMIT", f"output.{name}", "document exceeds its byte bound")
903
+ temporary = f".{name}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
904
+ try:
905
+ _write_regular_at(
906
+ self._root_fd, temporary, raw, prefix=self._prefix, refusal=self._refusal
907
+ )
908
+ os.replace(temporary, name, src_dir_fd=self._root_fd, dst_dir_fd=self._root_fd)
909
+ os.fsync(self._root_fd)
910
+ except OSError as error:
911
+ raise self._refuse(
912
+ "OUTPUT_IO", f"output.{name}", "document publication failed"
913
+ ) from error
914
+ finally:
915
+ with contextlib.suppress(FileNotFoundError):
916
+ os.unlink(temporary, dir_fd=self._root_fd)
917
+ if (
918
+ _read_regular_at(
919
+ self._root_fd, name, maximum=maximum, prefix=self._prefix, refusal=self._refusal
920
+ )
921
+ != raw
922
+ ):
923
+ raise self._refuse("OUTPUT_READBACK", f"output.{name}", "document readback differs")
924
+ self.bytes_written += len(raw)
925
+ return sha256_bytes(raw)
926
+
927
+
928
+ def _read_regular_at(
929
+ parent_fd: int,
930
+ name: str,
931
+ *,
932
+ maximum: int,
933
+ prefix: str,
934
+ refusal: type[SourceContractError] = CatalogIdentityRefused,
935
+ ) -> bytes:
936
+ try:
937
+ raw = read_bounded_at(parent_fd, name, maximum=maximum)
938
+ except BoundedReadFailure as error:
939
+ raise refusal(
940
+ f"{prefix}_OUTPUT_PATH",
941
+ "output",
942
+ "members must be stable bounded single-link regular files",
943
+ ) from error
944
+ assert raw is not None
945
+ return raw
946
+
947
+
948
+ def _write_regular_at(
949
+ parent_fd: int,
950
+ name: str,
951
+ raw: bytes,
952
+ *,
953
+ prefix: str,
954
+ refusal: type[SourceContractError] = CatalogIdentityRefused,
955
+ ) -> None:
956
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
957
+ descriptor = os.open(name, flags, 0o600, dir_fd=parent_fd)
958
+ try:
959
+ written = 0
960
+ while written < len(raw):
961
+ count = os.write(descriptor, raw[written:])
962
+ if count < 1:
963
+ raise refusal(f"{prefix}_OUTPUT_IO", "output", "write made no progress")
964
+ written += count
965
+ os.fsync(descriptor)
966
+ finally:
967
+ os.close(descriptor)
968
+
969
+
970
+ class IdentityHistoryWriter:
971
+ """Write one identity generation as immutable ascending packs behind one small manifest."""
972
+
973
+ def __init__(
974
+ self,
975
+ root: Path,
976
+ *,
977
+ provider_id: str,
978
+ sweep_sha256: str,
979
+ predecessor_manifest_sha256: str | None = None,
980
+ max_pack_records: int = MAX_IDENTITY_PACK_RECORDS,
981
+ max_pack_bytes: int = MAX_IDENTITY_PACK_BYTES,
982
+ max_records: int = MAX_IDENTITY_RECORDS,
983
+ output_descriptor: int | None = None,
984
+ ) -> None:
985
+ _identifier(provider_id, "identity.provider_id")
986
+ _digest(sweep_sha256, "identity.sweep_sha256")
987
+ _optional_digest(predecessor_manifest_sha256, "identity.predecessor_manifest_sha256")
988
+ for name, value in (
989
+ ("max_pack_records", max_pack_records),
990
+ ("max_pack_bytes", max_pack_bytes),
991
+ ("max_records", max_records),
992
+ ):
993
+ if type(value) is not int or value < 1:
994
+ raise CatalogIdentityRefused(
995
+ "IDENTITY_LIMIT", f"identity.{name}", "must be a positive integer"
996
+ )
997
+ if max_pack_records > MAX_IDENTITY_PACK_RECORDS or max_pack_bytes > MAX_IDENTITY_PACK_BYTES:
998
+ raise CatalogIdentityRefused(
999
+ "IDENTITY_LIMIT",
1000
+ "identity.max_pack_records",
1001
+ "pack bounds may not exceed the fixed residency contract",
1002
+ )
1003
+ self.provider_id = provider_id
1004
+ self.sweep_sha256 = sweep_sha256
1005
+ self.predecessor_manifest_sha256 = predecessor_manifest_sha256
1006
+ self.max_pack_records = max_pack_records
1007
+ self.max_pack_bytes = max_pack_bytes
1008
+ self.max_records = max_records
1009
+ self.packs: list[dict[str, Any]] = []
1010
+ self.record_count = 0
1011
+ self.version_count = 0
1012
+ self.observation_count = 0
1013
+ self.absence_count = 0
1014
+ self.disposition_count = 0
1015
+ self._directory = DurableDirectory(
1016
+ root,
1017
+ member_dirname=IDENTITY_PACKS_DIRNAME,
1018
+ code_prefix="IDENTITY",
1019
+ root_descriptor=output_descriptor,
1020
+ )
1021
+ self._buffer: list[dict[str, Any]] = []
1022
+ self._buffered_bytes = 0
1023
+ self._last_sort_key: bytes | None = None
1024
+ self._entry_ids: set[str] = set()
1025
+ self._published = False
1026
+
1027
+ @contextlib.contextmanager
1028
+ def opened(self) -> Iterator[IdentityHistoryWriter]:
1029
+ with self._directory.opened():
1030
+ existing = self._directory.read(
1031
+ IDENTITY_MANIFEST_FILENAME, maximum=MAX_IDENTITY_MANIFEST_BYTES
1032
+ )
1033
+ if existing is not None:
1034
+ raise CatalogIdentityRefused(
1035
+ "IDENTITY_OUTPUT_EXISTS",
1036
+ "identity.output",
1037
+ "a published identity generation is immutable; write a new root",
1038
+ )
1039
+ yield self
1040
+
1041
+ def append(self, history: IdentityHistory) -> None:
1042
+ """Append one record; keys must ascend and entry ids must never collide."""
1043
+
1044
+ _require_history(history)
1045
+ if history.identity.provider_id != self.provider_id:
1046
+ raise CatalogIdentityRefused(
1047
+ "IDENTITY_CONTRACT",
1048
+ "identity.provider_id",
1049
+ "one generation carries exactly one provider",
1050
+ )
1051
+ if self._last_sort_key is not None:
1052
+ if history.sort_key == self._last_sort_key:
1053
+ raise CatalogIdentityRefused(
1054
+ "IDENTITY_DUPLICATE_HEAD",
1055
+ "identity.records",
1056
+ "one provider record may appear at most once in a generation",
1057
+ )
1058
+ if history.sort_key < self._last_sort_key:
1059
+ raise CatalogIdentityRefused(
1060
+ "IDENTITY_ORDER",
1061
+ "identity.records",
1062
+ "records must be appended in ascending provider record order",
1063
+ )
1064
+ if history.identity.entry_id in self._entry_ids:
1065
+ raise CatalogIdentityRefused(
1066
+ "IDENTITY_COLLISION",
1067
+ "identity.entry_id",
1068
+ "one catalog entry id may not be bound to two provider records",
1069
+ )
1070
+ if self.record_count >= self.max_records:
1071
+ raise CatalogIdentityRefused(
1072
+ "IDENTITY_RECORD_LIMIT",
1073
+ "identity.records",
1074
+ f"one generation carries at most {self.max_records} records",
1075
+ )
1076
+ payload = history.to_dict()
1077
+ size = len(canonical_json_bytes(payload))
1078
+ if self._buffer and (
1079
+ len(self._buffer) >= self.max_pack_records
1080
+ or self._buffered_bytes + size + len(self._buffer) + 4_096 > self.max_pack_bytes
1081
+ ):
1082
+ self._flush()
1083
+ self._buffer.append(payload)
1084
+ self._buffered_bytes += size
1085
+ self._entry_ids.add(history.identity.entry_id)
1086
+ self._last_sort_key = history.sort_key
1087
+ self.record_count += 1
1088
+ self.version_count += len(history.versions)
1089
+ self.observation_count += len(history.observations)
1090
+ self.absence_count += len(history.absences)
1091
+ self.disposition_count += len(history.dispositions)
1092
+
1093
+ def _flush(self) -> None:
1094
+ if not self._buffer:
1095
+ return
1096
+ payload = {
1097
+ "schema_version": IDENTITY_PACK_SCHEMA,
1098
+ "provider_id": self.provider_id,
1099
+ "sweep_sha256": self.sweep_sha256,
1100
+ "first_provider_record_id": self._buffer[0]["identity"]["provider_record_id"],
1101
+ "last_provider_record_id": self._buffer[-1]["identity"]["provider_record_id"],
1102
+ "records": self._buffer,
1103
+ }
1104
+ digest, size = self._directory.write_member(payload, maximum=self.max_pack_bytes)
1105
+ self.packs.append(
1106
+ {
1107
+ "sha256": digest,
1108
+ "bytes": size,
1109
+ "record_count": len(self._buffer),
1110
+ "first_provider_record_id": payload["first_provider_record_id"],
1111
+ "last_provider_record_id": payload["last_provider_record_id"],
1112
+ }
1113
+ )
1114
+ self._buffer = []
1115
+ self._buffered_bytes = 0
1116
+
1117
+ def publish(self) -> str:
1118
+ """Flush the open pack and publish the manifest last; returns its exact digest."""
1119
+
1120
+ if self._published:
1121
+ raise CatalogIdentityRefused(
1122
+ "IDENTITY_OUTPUT_EXISTS",
1123
+ "identity.output",
1124
+ "this generation was already published",
1125
+ )
1126
+ self._flush()
1127
+ body = {
1128
+ "schema_version": IDENTITY_MANIFEST_SCHEMA,
1129
+ "provider_id": self.provider_id,
1130
+ "sweep_sha256": self.sweep_sha256,
1131
+ "predecessor_manifest_sha256": self.predecessor_manifest_sha256,
1132
+ "record_count": self.record_count,
1133
+ "version_count": self.version_count,
1134
+ "observation_count": self.observation_count,
1135
+ "absence_count": self.absence_count,
1136
+ "disposition_count": self.disposition_count,
1137
+ "packs": self.packs,
1138
+ }
1139
+ manifest = {**body, "root_sha256": canonical_sha256(body)}
1140
+ digest = self._directory.publish(
1141
+ IDENTITY_MANIFEST_FILENAME, manifest, maximum=MAX_IDENTITY_MANIFEST_BYTES
1142
+ )
1143
+ self._published = True
1144
+ return digest
1145
+
1146
+
1147
+ class IdentityHistoryStore:
1148
+ """Read one identity generation while keeping at most one pack resident."""
1149
+
1150
+ def __init__(
1151
+ self,
1152
+ root: Path,
1153
+ manifest: dict[str, Any],
1154
+ manifest_sha256: str,
1155
+ *,
1156
+ root_descriptor: int | None = None,
1157
+ ) -> None:
1158
+ self.root = Path(root)
1159
+ self.manifest = manifest
1160
+ self.manifest_sha256 = manifest_sha256
1161
+ self._root_descriptor = root_descriptor
1162
+ self._cached_sha256: str | None = None
1163
+ self._cached_records: list[dict[str, Any]] = []
1164
+
1165
+ @classmethod
1166
+ def open(
1167
+ cls,
1168
+ root: Path,
1169
+ *,
1170
+ expected_manifest_sha256: str | None = None,
1171
+ root_descriptor: int | None = None,
1172
+ ) -> IdentityHistoryStore:
1173
+ try:
1174
+ if root_descriptor is None:
1175
+ raw = read_bounded_path(
1176
+ Path(root) / IDENTITY_MANIFEST_FILENAME,
1177
+ maximum=MAX_IDENTITY_MANIFEST_BYTES,
1178
+ )
1179
+ assert raw is not None
1180
+ else:
1181
+ raw = _read_regular_at(
1182
+ root_descriptor,
1183
+ IDENTITY_MANIFEST_FILENAME,
1184
+ maximum=MAX_IDENTITY_MANIFEST_BYTES,
1185
+ prefix="IDENTITY",
1186
+ refusal=CatalogIdentityRefused,
1187
+ )
1188
+ except (OSError, BoundedReadFailure) as error:
1189
+ raise CatalogIdentityRefused(
1190
+ "IDENTITY_MANIFEST_MISMATCH",
1191
+ "identity.manifest",
1192
+ "no readable identity manifest at this root",
1193
+ ) from error
1194
+ if len(raw) > MAX_IDENTITY_MANIFEST_BYTES:
1195
+ raise CatalogIdentityRefused(
1196
+ "IDENTITY_MANIFEST_CORRUPT", "identity.manifest", "manifest exceeds its byte bound"
1197
+ )
1198
+ digest = sha256_bytes(raw)
1199
+ if expected_manifest_sha256 is not None and digest != expected_manifest_sha256:
1200
+ raise CatalogIdentityRefused(
1201
+ "IDENTITY_MANIFEST_MISMATCH",
1202
+ "identity.manifest",
1203
+ "the retained manifest is not the caller's exact generation",
1204
+ )
1205
+ try:
1206
+ manifest = parse_canonical_json(raw)
1207
+ except CanonicalJSONError as error:
1208
+ raise CatalogIdentityRefused(
1209
+ "IDENTITY_MANIFEST_CORRUPT", "identity.manifest", "manifest is not canonical"
1210
+ ) from error
1211
+ _validate_manifest(manifest)
1212
+ return cls(Path(root), manifest, digest, root_descriptor=root_descriptor)
1213
+
1214
+ @property
1215
+ def provider_id(self) -> str:
1216
+ return self.manifest["provider_id"]
1217
+
1218
+ @property
1219
+ def sweep_sha256(self) -> str:
1220
+ return self.manifest["sweep_sha256"]
1221
+
1222
+ @property
1223
+ def record_count(self) -> int:
1224
+ return self.manifest["record_count"]
1225
+
1226
+ @property
1227
+ def packs(self) -> list[dict[str, Any]]:
1228
+ return self.manifest["packs"]
1229
+
1230
+ @property
1231
+ def resident_packs(self) -> int:
1232
+ return 0 if self._cached_sha256 is None else 1
1233
+
1234
+ @property
1235
+ def resident_records(self) -> int:
1236
+ return len(self._cached_records)
1237
+
1238
+ def stream(self) -> Iterator[IdentityHistory]:
1239
+ """Yield every record in ascending provider record order, one pack resident."""
1240
+
1241
+ last: bytes | None = None
1242
+ for descriptor in self.packs:
1243
+ for payload in self._pack(descriptor):
1244
+ history = identity_history_from_dict(payload)
1245
+ if last is not None and history.sort_key <= last:
1246
+ raise CatalogIdentityRefused(
1247
+ "IDENTITY_ORDER",
1248
+ "identity.records",
1249
+ "a retained generation must ascend by provider record",
1250
+ )
1251
+ last = history.sort_key
1252
+ yield history
1253
+
1254
+ def lookup(self, provider_record_id: str) -> IdentityHistory | None:
1255
+ """Return one record by reading at most the one pack whose range could carry it."""
1256
+
1257
+ key = provider_record_id.encode("utf-8")
1258
+ for descriptor in self.packs:
1259
+ first = descriptor["first_provider_record_id"].encode("utf-8")
1260
+ last = descriptor["last_provider_record_id"].encode("utf-8")
1261
+ if key < first:
1262
+ return None
1263
+ if key > last:
1264
+ continue
1265
+ for payload in self._pack(descriptor):
1266
+ if payload["identity"]["provider_record_id"] == provider_record_id:
1267
+ return identity_history_from_dict(payload)
1268
+ return None
1269
+ return None
1270
+
1271
+ def explicit_mapping(self, *, maximum: int = MAX_EXPLICIT_MAPPING) -> dict[str, str]:
1272
+ """Materialize the bounded v1 provider-record-to-entry mapping, or refuse to."""
1273
+
1274
+ if self.record_count > maximum:
1275
+ raise CatalogIdentityRefused(
1276
+ "IDENTITY_MAPPING_LIMIT",
1277
+ "identity.explicit_mapping",
1278
+ f"the explicit mapping path carries at most {maximum} records",
1279
+ )
1280
+ return {
1281
+ history.identity.provider_record_id: history.identity.entry_id
1282
+ for history in self.stream()
1283
+ }
1284
+
1285
+ def _pack(self, descriptor: dict[str, Any]) -> list[dict[str, Any]]:
1286
+ if self._cached_sha256 == descriptor["sha256"]:
1287
+ return self._cached_records
1288
+ self._cached_sha256 = None
1289
+ self._cached_records = []
1290
+ try:
1291
+ if self._root_descriptor is None:
1292
+ raw = read_bounded_path(
1293
+ self.root / IDENTITY_PACKS_DIRNAME / f"{descriptor['sha256']}.json",
1294
+ maximum=MAX_IDENTITY_PACK_BYTES,
1295
+ )
1296
+ assert raw is not None
1297
+ else:
1298
+ members = os.open(
1299
+ IDENTITY_PACKS_DIRNAME, _OPEN_DIRECTORY, dir_fd=self._root_descriptor
1300
+ )
1301
+ try:
1302
+ raw = _read_regular_at(
1303
+ members,
1304
+ f"{descriptor['sha256']}.json",
1305
+ maximum=MAX_IDENTITY_PACK_BYTES,
1306
+ prefix="IDENTITY",
1307
+ refusal=CatalogIdentityRefused,
1308
+ )
1309
+ finally:
1310
+ os.close(members)
1311
+ except (OSError, BoundedReadFailure) as error:
1312
+ raise CatalogIdentityRefused(
1313
+ "IDENTITY_PACK_READBACK", "identity.pack", "a committed pack is unreadable"
1314
+ ) from error
1315
+ if len(raw) != descriptor["bytes"] or sha256_bytes(raw) != descriptor["sha256"]:
1316
+ raise CatalogIdentityRefused(
1317
+ "IDENTITY_PACK_READBACK", "identity.pack", "a committed pack differs"
1318
+ )
1319
+ try:
1320
+ payload = parse_canonical_json(raw)
1321
+ except CanonicalJSONError as error:
1322
+ raise CatalogIdentityRefused(
1323
+ "IDENTITY_PACK_READBACK", "identity.pack", "pack is not canonical"
1324
+ ) from error
1325
+ if (
1326
+ not isinstance(payload, dict)
1327
+ or payload.get("schema_version") != IDENTITY_PACK_SCHEMA
1328
+ or not isinstance(payload.get("records"), list)
1329
+ or len(payload["records"]) != descriptor["record_count"]
1330
+ ):
1331
+ raise CatalogIdentityRefused(
1332
+ "IDENTITY_PACK_READBACK", "identity.pack", "pack contract differs"
1333
+ )
1334
+ self._cached_sha256 = descriptor["sha256"]
1335
+ self._cached_records = payload["records"]
1336
+ return self._cached_records
1337
+
1338
+
1339
+ def write_identity_generation(
1340
+ root: Path,
1341
+ histories: Iterable[IdentityHistory],
1342
+ *,
1343
+ provider_id: str,
1344
+ sweep_sha256: str,
1345
+ predecessor_manifest_sha256: str | None = None,
1346
+ max_pack_records: int = MAX_IDENTITY_PACK_RECORDS,
1347
+ ) -> str:
1348
+ """Write one complete generation from an ascending iterable; returns the manifest digest."""
1349
+
1350
+ writer = IdentityHistoryWriter(
1351
+ root,
1352
+ provider_id=provider_id,
1353
+ sweep_sha256=sweep_sha256,
1354
+ predecessor_manifest_sha256=predecessor_manifest_sha256,
1355
+ max_pack_records=max_pack_records,
1356
+ )
1357
+ with writer.opened():
1358
+ for history in histories:
1359
+ writer.append(history)
1360
+ return writer.publish()
1361
+
1362
+
1363
+ # ------------------------------------------------------------------------------------------
1364
+ # Strict rebuilding
1365
+ # ------------------------------------------------------------------------------------------
1366
+
1367
+
1368
+ def identity_history_from_dict(value: Any) -> IdentityHistory:
1369
+ """Rebuild exactly one persisted record shape; never repair or infer a missing member."""
1370
+
1371
+ expected = {
1372
+ "schema_version",
1373
+ "identity",
1374
+ "policy_sha256",
1375
+ "harvester_coordinate",
1376
+ "versions",
1377
+ "observations",
1378
+ "absences",
1379
+ "dispositions",
1380
+ }
1381
+ if not isinstance(value, dict) or set(value) != expected:
1382
+ raise CatalogIdentityRefused(
1383
+ "IDENTITY_CONTRACT", "identity.record", "persisted record fields differ"
1384
+ )
1385
+ identity = value["identity"]
1386
+ if not isinstance(identity, dict) or set(identity) != {
1387
+ "provider_id",
1388
+ "provider_record_id",
1389
+ "entry_id",
1390
+ "derivation",
1391
+ }:
1392
+ raise CatalogIdentityRefused(
1393
+ "IDENTITY_CONTRACT", "identity.record.identity", "persisted identity fields differ"
1394
+ )
1395
+ try:
1396
+ return IdentityHistory(
1397
+ identity=ProviderIdentity(**identity),
1398
+ policy_sha256=value["policy_sha256"],
1399
+ harvester_coordinate=value["harvester_coordinate"],
1400
+ versions=tuple(
1401
+ SemanticVersionLink(
1402
+ entry_version=item["entry_version"],
1403
+ semantic_facts_digest=item["semantic_facts_digest"],
1404
+ previous_semantic_digest=item["previous_semantic_digest"],
1405
+ observed_at=item["observed_at"],
1406
+ sweep_sha256=item["sweep_sha256"],
1407
+ provider_record_sha256=item["provider_record_sha256"],
1408
+ vector_sha256s=tuple(item["vector_sha256s"]),
1409
+ published=item["published"],
1410
+ )
1411
+ for item in value["versions"]
1412
+ ),
1413
+ observations=tuple(ObservationEvidence(**item) for item in value["observations"]),
1414
+ absences=tuple(AbsenceEvidence(**item) for item in value["absences"]),
1415
+ dispositions=tuple(DispositionEvidence(**item) for item in value["dispositions"]),
1416
+ schema_version=value["schema_version"],
1417
+ )
1418
+ except CatalogIdentityRefused:
1419
+ raise
1420
+ except (KeyError, TypeError, ValueError) as error:
1421
+ raise CatalogIdentityRefused(
1422
+ "IDENTITY_CONTRACT", "identity.record", "persisted record is invalid"
1423
+ ) from error
1424
+
1425
+
1426
+ def _validate_manifest(manifest: Any) -> None:
1427
+ expected = {
1428
+ "schema_version",
1429
+ "provider_id",
1430
+ "sweep_sha256",
1431
+ "predecessor_manifest_sha256",
1432
+ "record_count",
1433
+ "version_count",
1434
+ "observation_count",
1435
+ "absence_count",
1436
+ "disposition_count",
1437
+ "packs",
1438
+ "root_sha256",
1439
+ }
1440
+ if (
1441
+ not isinstance(manifest, dict)
1442
+ or set(manifest) != expected
1443
+ or manifest["schema_version"] != IDENTITY_MANIFEST_SCHEMA
1444
+ or not isinstance(manifest["packs"], list)
1445
+ or type(manifest["record_count"]) is not int
1446
+ ):
1447
+ raise CatalogIdentityRefused(
1448
+ "IDENTITY_MANIFEST_CORRUPT", "identity.manifest", "manifest contract differs"
1449
+ )
1450
+ body = {key: value for key, value in manifest.items() if key != "root_sha256"}
1451
+ if canonical_sha256(body) != manifest["root_sha256"]:
1452
+ raise CatalogIdentityRefused(
1453
+ "IDENTITY_MANIFEST_CORRUPT", "identity.manifest", "manifest root digest differs"
1454
+ )
1455
+ total = 0
1456
+ for descriptor in manifest["packs"]:
1457
+ if (
1458
+ not isinstance(descriptor, dict)
1459
+ or set(descriptor)
1460
+ != {
1461
+ "sha256",
1462
+ "bytes",
1463
+ "record_count",
1464
+ "first_provider_record_id",
1465
+ "last_provider_record_id",
1466
+ }
1467
+ or not _is_digest(descriptor["sha256"])
1468
+ or type(descriptor["bytes"]) is not int
1469
+ or type(descriptor["record_count"]) is not int
1470
+ ):
1471
+ raise CatalogIdentityRefused(
1472
+ "IDENTITY_MANIFEST_CORRUPT", "identity.manifest", "pack descriptor is invalid"
1473
+ )
1474
+ total += descriptor["record_count"]
1475
+ if total != manifest["record_count"]:
1476
+ raise CatalogIdentityRefused(
1477
+ "IDENTITY_MANIFEST_CORRUPT",
1478
+ "identity.manifest",
1479
+ "pack descriptors do not account for the declared record count",
1480
+ )
1481
+
1482
+
1483
+ # ------------------------------------------------------------------------------------------
1484
+ # Small closed validators
1485
+ # ------------------------------------------------------------------------------------------
1486
+
1487
+
1488
+ def _require_history(value: Any) -> None:
1489
+ if not isinstance(value, IdentityHistory):
1490
+ raise CatalogIdentityRefused("IDENTITY_CONTRACT", "history", "must be an IdentityHistory")
1491
+
1492
+
1493
+ def _text(value: Any, path: str, *, maximum: int) -> str:
1494
+ if not isinstance(value, str) or not value or len(value) > maximum:
1495
+ raise CatalogIdentityRefused(
1496
+ "IDENTITY_CONTRACT", path, f"must be a non-empty string of at most {maximum} characters"
1497
+ )
1498
+ for character in value:
1499
+ codepoint = ord(character)
1500
+ if (
1501
+ 0xD800 <= codepoint <= 0xDFFF
1502
+ or codepoint < 0x20
1503
+ or 0x7F <= codepoint <= 0x9F
1504
+ or (character.isspace() and character != " ")
1505
+ ):
1506
+ raise CatalogIdentityRefused(
1507
+ "IDENTITY_CONTRACT", path, "must not contain unsafe or control characters"
1508
+ )
1509
+ return value
1510
+
1511
+
1512
+ def _identifier(value: Any, path: str) -> str:
1513
+ text = _text(value, path, maximum=MAX_ENTRY_ID)
1514
+ if not _IDENTIFIER.fullmatch(text):
1515
+ raise CatalogIdentityRefused(
1516
+ "IDENTITY_CONTRACT", path, "must be a lowercase dotted/dashed/underscored identifier"
1517
+ )
1518
+ return text
1519
+
1520
+
1521
+ def _entry_id(value: Any, path: str) -> str:
1522
+ return _identifier(value, path)
1523
+
1524
+
1525
+ def _digest(value: Any, path: str) -> str:
1526
+ if not _is_digest(value):
1527
+ raise CatalogIdentityRefused(
1528
+ "IDENTITY_CONTRACT", path, "must be a lowercase 64-character SHA-256"
1529
+ )
1530
+ return value
1531
+
1532
+
1533
+ def _optional_digest(value: Any, path: str) -> str | None:
1534
+ if value is None:
1535
+ return None
1536
+ return _digest(value, path)
1537
+
1538
+
1539
+ def _utc_second(value: Any, path: str) -> str:
1540
+ text = _text(value, path, maximum=20)
1541
+ if not _UTC_SECOND.fullmatch(text):
1542
+ raise CatalogIdentityRefused(
1543
+ "IDENTITY_CONTRACT", path, "must be canonical UTC to whole seconds"
1544
+ )
1545
+ return text
1546
+
1547
+
1548
+ def _is_digest(value: Any) -> bool:
1549
+ return isinstance(value, str) and _SHA256.fullmatch(value) is not None