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.
- mostlyright/data_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- 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
|