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,761 @@
|
|
|
1
|
+
"""Crash-recoverable retention and immutable deletion-status projection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from mostlyright.data_harness.acquisition.http import (
|
|
18
|
+
exclusive_content_addressed_name,
|
|
19
|
+
seal_content_addressed_snapshot,
|
|
20
|
+
)
|
|
21
|
+
from mostlyright.data_harness.acquisition.retention_admission import retention_evidence_root
|
|
22
|
+
from mostlyright.data_harness.canonical import (
|
|
23
|
+
canonical_json_bytes,
|
|
24
|
+
canonical_sha256,
|
|
25
|
+
parse_json,
|
|
26
|
+
)
|
|
27
|
+
from mostlyright.data_harness.formats import SNAPSHOT_SUFFIX_PATTERN
|
|
28
|
+
from mostlyright.data_harness.sources.contracts import (
|
|
29
|
+
RETENTION_KINDS,
|
|
30
|
+
DeletedReference,
|
|
31
|
+
DeletionTombstone,
|
|
32
|
+
RetentionPolicy,
|
|
33
|
+
SourceContractError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Compiled once at import time, as before, but built from the single closed table so a
|
|
37
|
+
# newly admitted format cannot become parseable while staying invisible to deletion.
|
|
38
|
+
_SNAPSHOT_NAME = re.compile(r"^(?P<digest>[0-9a-f]{64})\.(?:" + SNAPSHOT_SUFFIX_PATTERN + r")$")
|
|
39
|
+
_DELETION_STATUS_VERSION = "harness-source-deletion-status.v1"
|
|
40
|
+
_AUDIT_VERSION = "harness-source-deletion-audit.v1"
|
|
41
|
+
_STATUS_VERSION = "harness-source-availability.v1"
|
|
42
|
+
_KIND_DAYS = {
|
|
43
|
+
"raw": "raw_days",
|
|
44
|
+
"derived": "derived_days",
|
|
45
|
+
"logs": "log_days",
|
|
46
|
+
"review_evidence": "review_evidence_days",
|
|
47
|
+
}
|
|
48
|
+
assert set(_KIND_DAYS) == set(RETENTION_KINDS), (
|
|
49
|
+
"retention kinds and their policy day fields must stay in step"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class RetentionDecision:
|
|
55
|
+
"""Expiry of one evidence kind under a retention policy; derived by retention_decision."""
|
|
56
|
+
|
|
57
|
+
evidence_kind: str
|
|
58
|
+
retained_until: str
|
|
59
|
+
expired: bool
|
|
60
|
+
policy_digest: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class DeletionOutcome:
|
|
65
|
+
"""Evidence sealed by DeletionCoordinator.delete for one removed snapshot.
|
|
66
|
+
|
|
67
|
+
``status_envelope_path`` names the sealed ``status-<digest>.json`` deletion-status envelope.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
status_envelope_path: Path
|
|
71
|
+
tombstone_path: Path
|
|
72
|
+
audit_path: Path
|
|
73
|
+
tombstone: DeletionTombstone
|
|
74
|
+
status: dict[str, Any]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DeletionCoordinator:
|
|
78
|
+
"""Remove only exact content-addressed snapshots after sealing complete references."""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
snapshot_root: Path,
|
|
84
|
+
evidence_root: Path,
|
|
85
|
+
failure_injector: Callable[[str], None] | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
for name, root in (("snapshot_root", snapshot_root), ("evidence_root", evidence_root)):
|
|
88
|
+
if not root.is_absolute() or root.is_symlink() or not root.is_dir():
|
|
89
|
+
raise SourceContractError(
|
|
90
|
+
"DELETION_ROOT",
|
|
91
|
+
name,
|
|
92
|
+
"must be an existing absolute non-symlink directory",
|
|
93
|
+
)
|
|
94
|
+
expected_evidence_root = retention_evidence_root(snapshot_root)
|
|
95
|
+
if evidence_root != expected_evidence_root:
|
|
96
|
+
raise SourceContractError(
|
|
97
|
+
"DELETION_ROOT",
|
|
98
|
+
"evidence_root",
|
|
99
|
+
"must be the deterministic sibling authority root for snapshot_root",
|
|
100
|
+
)
|
|
101
|
+
self._snapshot_root = snapshot_root
|
|
102
|
+
self._evidence_root = evidence_root
|
|
103
|
+
self._failure_injector = failure_injector
|
|
104
|
+
|
|
105
|
+
def delete(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
storage_name: str,
|
|
109
|
+
content_sha256: str,
|
|
110
|
+
removed_at: str,
|
|
111
|
+
reason_code: str,
|
|
112
|
+
policy: RetentionPolicy,
|
|
113
|
+
references: tuple[DeletedReference, ...],
|
|
114
|
+
) -> DeletionOutcome:
|
|
115
|
+
match = _SNAPSHOT_NAME.fullmatch(storage_name)
|
|
116
|
+
if match is None or match.group("digest") != content_sha256:
|
|
117
|
+
raise SourceContractError(
|
|
118
|
+
"DELETION_TARGET",
|
|
119
|
+
"storage_name",
|
|
120
|
+
"target must be the exact content-addressed snapshot name",
|
|
121
|
+
)
|
|
122
|
+
ordered = _complete_reference_set(references)
|
|
123
|
+
reference_digest = canonical_sha256([item.to_dict() for item in ordered])
|
|
124
|
+
tombstone = DeletionTombstone(
|
|
125
|
+
tombstone_id=f"delete.{content_sha256[:24]}",
|
|
126
|
+
removed_content_sha256=content_sha256,
|
|
127
|
+
removed_at=removed_at,
|
|
128
|
+
reason_code=reason_code,
|
|
129
|
+
policy_digest=policy.digest,
|
|
130
|
+
complete_reference_set_digest=reference_digest,
|
|
131
|
+
references=ordered,
|
|
132
|
+
byte_removal_confirmed=True,
|
|
133
|
+
)
|
|
134
|
+
deletion_status = {
|
|
135
|
+
"schema_version": _DELETION_STATUS_VERSION,
|
|
136
|
+
"content_sha256": content_sha256,
|
|
137
|
+
"status": "deletion-pending",
|
|
138
|
+
"source_access_allowed": False,
|
|
139
|
+
"refetch_allowed": False,
|
|
140
|
+
"replay": "unavailable-by-deletion",
|
|
141
|
+
"removed_at": removed_at,
|
|
142
|
+
"reason_code": reason_code,
|
|
143
|
+
"policy_digest": policy.digest,
|
|
144
|
+
"complete_reference_set_digest": reference_digest,
|
|
145
|
+
"references": [item.to_dict() for item in ordered],
|
|
146
|
+
"planned_tombstone_digest": tombstone.digest,
|
|
147
|
+
}
|
|
148
|
+
deletion_status_bytes = canonical_json_bytes(deletion_status)
|
|
149
|
+
deletion_status_name = f"status-{content_sha256}.json"
|
|
150
|
+
# Held from before the target is opened until after its name is gone. A sealer of this
|
|
151
|
+
# same name is validating bytes it has open, and an unlink landing between two of its
|
|
152
|
+
# observations moves st_nlink from 1 to 0 under its descriptor -- the exact refusal issue
|
|
153
|
+
# #209 reported. Taking the sealer's own per-name lock is what stops the harness racing
|
|
154
|
+
# itself, in this process and in any other. The evidence envelopes sealed inside this
|
|
155
|
+
# block take a lock of their own, under the separate evidence root the constructor
|
|
156
|
+
# requires; that direction is the only nesting in the tree, so nothing waits on a cycle.
|
|
157
|
+
with exclusive_content_addressed_name(self._snapshot_root, storage_name):
|
|
158
|
+
target = self._open_validated_snapshot(
|
|
159
|
+
storage_name=storage_name,
|
|
160
|
+
content_sha256=content_sha256,
|
|
161
|
+
)
|
|
162
|
+
if target is None:
|
|
163
|
+
if not _keyed_envelope_matches(
|
|
164
|
+
self._evidence_root,
|
|
165
|
+
content=deletion_status_bytes,
|
|
166
|
+
index_name=deletion_status_name,
|
|
167
|
+
):
|
|
168
|
+
raise SourceContractError(
|
|
169
|
+
"DELETION_TARGET",
|
|
170
|
+
"snapshot",
|
|
171
|
+
"snapshot is absent without exact recoverable deletion-pending evidence",
|
|
172
|
+
)
|
|
173
|
+
status_envelope_path = self._evidence_root / deletion_status_name
|
|
174
|
+
else:
|
|
175
|
+
root_fd, descriptor, identity = target
|
|
176
|
+
try:
|
|
177
|
+
self._checkpoint("before_deletion_status")
|
|
178
|
+
status_envelope_path = _seal_keyed_envelope(
|
|
179
|
+
self._evidence_root,
|
|
180
|
+
content=deletion_status_bytes,
|
|
181
|
+
index_name=deletion_status_name,
|
|
182
|
+
)
|
|
183
|
+
self._checkpoint("after_deletion_status")
|
|
184
|
+
self._checkpoint("before_unlink")
|
|
185
|
+
self._unlink_validated_snapshot(
|
|
186
|
+
root_fd=root_fd,
|
|
187
|
+
descriptor=descriptor,
|
|
188
|
+
storage_name=storage_name,
|
|
189
|
+
identity=identity,
|
|
190
|
+
)
|
|
191
|
+
self._checkpoint("after_unlink")
|
|
192
|
+
self._checkpoint("before_snapshot_fsync")
|
|
193
|
+
os.fsync(root_fd)
|
|
194
|
+
self._checkpoint("after_snapshot_fsync")
|
|
195
|
+
finally:
|
|
196
|
+
os.close(descriptor)
|
|
197
|
+
os.close(root_fd)
|
|
198
|
+
|
|
199
|
+
self._checkpoint("before_tombstone")
|
|
200
|
+
tombstone_path = _seal_keyed_envelope(
|
|
201
|
+
self._evidence_root,
|
|
202
|
+
content=tombstone.canonical_bytes,
|
|
203
|
+
index_name=f"tombstone-{content_sha256}.json",
|
|
204
|
+
)
|
|
205
|
+
self._checkpoint("after_tombstone")
|
|
206
|
+
audit = {
|
|
207
|
+
"schema_version": _AUDIT_VERSION,
|
|
208
|
+
"event": "source_bytes_deleted",
|
|
209
|
+
"removed_content_sha256": content_sha256,
|
|
210
|
+
"removed_at": removed_at,
|
|
211
|
+
"reason_code": reason_code,
|
|
212
|
+
"policy_digest": policy.digest,
|
|
213
|
+
"complete_reference_set_digest": reference_digest,
|
|
214
|
+
"deletion_status_digest": _path_digest(status_envelope_path),
|
|
215
|
+
"tombstone_digest": tombstone.digest,
|
|
216
|
+
}
|
|
217
|
+
self._checkpoint("before_audit")
|
|
218
|
+
audit_path = _seal_keyed_envelope(
|
|
219
|
+
self._evidence_root,
|
|
220
|
+
content=canonical_json_bytes(audit),
|
|
221
|
+
index_name=f"audit-{content_sha256}.json",
|
|
222
|
+
)
|
|
223
|
+
self._checkpoint("after_audit")
|
|
224
|
+
status = project_availability(
|
|
225
|
+
content_sha256=content_sha256,
|
|
226
|
+
tombstone=tombstone,
|
|
227
|
+
deletion_status=deletion_status,
|
|
228
|
+
)
|
|
229
|
+
return DeletionOutcome(
|
|
230
|
+
status_envelope_path=status_envelope_path,
|
|
231
|
+
tombstone_path=tombstone_path,
|
|
232
|
+
audit_path=audit_path,
|
|
233
|
+
tombstone=tombstone,
|
|
234
|
+
status=status,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def _checkpoint(self, name: str) -> None:
|
|
238
|
+
if self._failure_injector is not None:
|
|
239
|
+
self._failure_injector(name)
|
|
240
|
+
|
|
241
|
+
def _open_validated_snapshot(
|
|
242
|
+
self,
|
|
243
|
+
*,
|
|
244
|
+
storage_name: str,
|
|
245
|
+
content_sha256: str,
|
|
246
|
+
) -> tuple[int, int, os.stat_result] | None:
|
|
247
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
248
|
+
root_fd = os.open(self._snapshot_root, directory_flags)
|
|
249
|
+
try:
|
|
250
|
+
try:
|
|
251
|
+
descriptor = os.open(
|
|
252
|
+
storage_name,
|
|
253
|
+
os.O_RDONLY
|
|
254
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
255
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
256
|
+
| getattr(os, "O_CLOEXEC", 0),
|
|
257
|
+
dir_fd=root_fd,
|
|
258
|
+
)
|
|
259
|
+
except FileNotFoundError:
|
|
260
|
+
os.close(root_fd)
|
|
261
|
+
return None
|
|
262
|
+
try:
|
|
263
|
+
before = os.fstat(descriptor)
|
|
264
|
+
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1:
|
|
265
|
+
raise SourceContractError(
|
|
266
|
+
"DELETION_TARGET",
|
|
267
|
+
"snapshot",
|
|
268
|
+
"deletion target must be one non-hardlinked regular file",
|
|
269
|
+
)
|
|
270
|
+
observed = _hash_descriptor(descriptor)
|
|
271
|
+
after = os.fstat(descriptor)
|
|
272
|
+
if (
|
|
273
|
+
observed != content_sha256
|
|
274
|
+
or before.st_dev != after.st_dev
|
|
275
|
+
or before.st_ino != after.st_ino
|
|
276
|
+
or before.st_size != after.st_size
|
|
277
|
+
or before.st_mtime_ns != after.st_mtime_ns
|
|
278
|
+
):
|
|
279
|
+
raise SourceContractError(
|
|
280
|
+
"DELETION_TARGET_CHANGED",
|
|
281
|
+
"snapshot",
|
|
282
|
+
"snapshot identity, bytes, or metadata changed during deletion validation",
|
|
283
|
+
)
|
|
284
|
+
return root_fd, descriptor, before
|
|
285
|
+
except Exception:
|
|
286
|
+
os.close(descriptor)
|
|
287
|
+
raise
|
|
288
|
+
except Exception:
|
|
289
|
+
os.close(root_fd)
|
|
290
|
+
raise
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _unlink_validated_snapshot(
|
|
294
|
+
*,
|
|
295
|
+
root_fd: int,
|
|
296
|
+
descriptor: int,
|
|
297
|
+
storage_name: str,
|
|
298
|
+
identity: os.stat_result,
|
|
299
|
+
) -> None:
|
|
300
|
+
after = os.fstat(descriptor)
|
|
301
|
+
if (
|
|
302
|
+
identity.st_dev != after.st_dev
|
|
303
|
+
or identity.st_ino != after.st_ino
|
|
304
|
+
or identity.st_size != after.st_size
|
|
305
|
+
or identity.st_mtime_ns != after.st_mtime_ns
|
|
306
|
+
):
|
|
307
|
+
raise SourceContractError(
|
|
308
|
+
"DELETION_TARGET_CHANGED",
|
|
309
|
+
"snapshot",
|
|
310
|
+
"snapshot identity or metadata changed after deletion evidence was sealed",
|
|
311
|
+
)
|
|
312
|
+
try:
|
|
313
|
+
current = os.stat(storage_name, dir_fd=root_fd, follow_symlinks=False)
|
|
314
|
+
except FileNotFoundError:
|
|
315
|
+
raise SourceContractError(
|
|
316
|
+
"DELETION_TARGET_CHANGED",
|
|
317
|
+
"snapshot",
|
|
318
|
+
"snapshot path disappeared after deletion evidence was sealed",
|
|
319
|
+
) from None
|
|
320
|
+
if current.st_dev != identity.st_dev or current.st_ino != identity.st_ino:
|
|
321
|
+
raise SourceContractError(
|
|
322
|
+
"DELETION_TARGET_CHANGED",
|
|
323
|
+
"snapshot",
|
|
324
|
+
"snapshot path no longer names the validated file",
|
|
325
|
+
)
|
|
326
|
+
os.unlink(storage_name, dir_fd=root_fd)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def retention_decision(
|
|
330
|
+
*,
|
|
331
|
+
evidence_kind: str,
|
|
332
|
+
ingested_at: str,
|
|
333
|
+
as_of: str,
|
|
334
|
+
policy: RetentionPolicy,
|
|
335
|
+
) -> RetentionDecision:
|
|
336
|
+
"""Derive expiry from exact UTC instants and the immutable policy."""
|
|
337
|
+
|
|
338
|
+
if evidence_kind not in _KIND_DAYS:
|
|
339
|
+
raise SourceContractError(
|
|
340
|
+
"RETENTION_KIND",
|
|
341
|
+
"evidence_kind",
|
|
342
|
+
f"must be one of {sorted(_KIND_DAYS)}",
|
|
343
|
+
)
|
|
344
|
+
ingested = _utc(ingested_at, "ingested_at")
|
|
345
|
+
observed = _utc(as_of, "as_of")
|
|
346
|
+
days = getattr(policy, _KIND_DAYS[evidence_kind])
|
|
347
|
+
retained_until = ingested + timedelta(days=days)
|
|
348
|
+
return RetentionDecision(
|
|
349
|
+
evidence_kind=evidence_kind,
|
|
350
|
+
retained_until=_format_utc(retained_until),
|
|
351
|
+
expired=observed >= retained_until,
|
|
352
|
+
policy_digest=policy.digest,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def project_availability(
|
|
357
|
+
*,
|
|
358
|
+
content_sha256: str,
|
|
359
|
+
tombstone: DeletionTombstone | None,
|
|
360
|
+
deletion_status: dict[str, Any] | None = None,
|
|
361
|
+
) -> dict[str, Any]:
|
|
362
|
+
"""Join immutable source identity with separate immutable deletion evidence."""
|
|
363
|
+
|
|
364
|
+
if deletion_status is not None:
|
|
365
|
+
_validate_deletion_status(deletion_status, content_sha256=content_sha256)
|
|
366
|
+
if tombstone is None and deletion_status is None:
|
|
367
|
+
status = "available"
|
|
368
|
+
tombstone_digest = None
|
|
369
|
+
replay = "exact-bytes-required"
|
|
370
|
+
elif tombstone is None:
|
|
371
|
+
status = "deletion-pending"
|
|
372
|
+
tombstone_digest = deletion_status["planned_tombstone_digest"]
|
|
373
|
+
replay = "unavailable-by-deletion"
|
|
374
|
+
else:
|
|
375
|
+
if tombstone.removed_content_sha256 != content_sha256:
|
|
376
|
+
raise SourceContractError(
|
|
377
|
+
"TOMBSTONE_BINDING",
|
|
378
|
+
"tombstone.removed_content_sha256",
|
|
379
|
+
"tombstone does not bind the requested source digest",
|
|
380
|
+
)
|
|
381
|
+
if (
|
|
382
|
+
deletion_status is not None
|
|
383
|
+
and deletion_status["planned_tombstone_digest"] != tombstone.digest
|
|
384
|
+
):
|
|
385
|
+
raise SourceContractError(
|
|
386
|
+
"TOMBSTONE_BINDING",
|
|
387
|
+
"deletion_status.planned_tombstone_digest",
|
|
388
|
+
"deletion status does not bind the confirmed tombstone",
|
|
389
|
+
)
|
|
390
|
+
status = "unavailable-by-deletion"
|
|
391
|
+
tombstone_digest = tombstone.digest
|
|
392
|
+
replay = "unavailable-by-deletion"
|
|
393
|
+
return {
|
|
394
|
+
"schema_version": _STATUS_VERSION,
|
|
395
|
+
"content_sha256": content_sha256,
|
|
396
|
+
"status": status,
|
|
397
|
+
"source_access_allowed": tombstone is None and deletion_status is None,
|
|
398
|
+
"refetch_allowed": False if tombstone is not None or deletion_status is not None else None,
|
|
399
|
+
"replay": replay,
|
|
400
|
+
"tombstone_digest": tombstone_digest,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def require_source_available(
|
|
405
|
+
*,
|
|
406
|
+
content_sha256: str,
|
|
407
|
+
tombstone: DeletionTombstone | None,
|
|
408
|
+
deletion_status: dict[str, Any] | None = None,
|
|
409
|
+
) -> None:
|
|
410
|
+
status = project_availability(
|
|
411
|
+
content_sha256=content_sha256,
|
|
412
|
+
tombstone=tombstone,
|
|
413
|
+
deletion_status=deletion_status,
|
|
414
|
+
)
|
|
415
|
+
if status["status"] != "available":
|
|
416
|
+
raise SourceContractError(
|
|
417
|
+
"SOURCE_UNAVAILABLE_BY_DELETION",
|
|
418
|
+
"source",
|
|
419
|
+
"source bytes were deleted; replay, resume, and refetch must not substitute them",
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def load_sealed_deletion_status(path: Path) -> dict[str, Any]:
|
|
424
|
+
"""Load strict immutable deletion-pending evidence for recovery and access denial."""
|
|
425
|
+
|
|
426
|
+
value = parse_json(path.read_bytes())
|
|
427
|
+
if not isinstance(value, dict):
|
|
428
|
+
raise SourceContractError("DELETION_STATUS", "deletion_status", "must be an object")
|
|
429
|
+
_validate_deletion_status(value, content_sha256=value.get("content_sha256"))
|
|
430
|
+
return value
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def load_sealed_tombstone(path: Path) -> DeletionTombstone:
|
|
434
|
+
"""Load and validate a sealed tombstone without accepting unknown fields."""
|
|
435
|
+
|
|
436
|
+
value = parse_json(path.read_bytes())
|
|
437
|
+
if not isinstance(value, dict):
|
|
438
|
+
raise SourceContractError("TOMBSTONE", "tombstone", "must be an object")
|
|
439
|
+
expected = {
|
|
440
|
+
"schema_version",
|
|
441
|
+
"tombstone_id",
|
|
442
|
+
"removed_content_sha256",
|
|
443
|
+
"removed_at",
|
|
444
|
+
"reason_code",
|
|
445
|
+
"policy_digest",
|
|
446
|
+
"complete_reference_set_digest",
|
|
447
|
+
"references",
|
|
448
|
+
"byte_removal_confirmed",
|
|
449
|
+
}
|
|
450
|
+
if set(value) != expected or not isinstance(value["references"], list):
|
|
451
|
+
raise SourceContractError(
|
|
452
|
+
"TOMBSTONE",
|
|
453
|
+
"tombstone",
|
|
454
|
+
"contains missing or unknown fields",
|
|
455
|
+
)
|
|
456
|
+
references: list[DeletedReference] = []
|
|
457
|
+
for item in value["references"]:
|
|
458
|
+
if not isinstance(item, dict) or set(item) != {"reference_kind", "reference_digest"}:
|
|
459
|
+
raise SourceContractError("TOMBSTONE", "references", "reference shape is invalid")
|
|
460
|
+
references.append(DeletedReference(**item))
|
|
461
|
+
copied = dict(value)
|
|
462
|
+
copied["references"] = tuple(references)
|
|
463
|
+
return DeletionTombstone(**copied)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _validate_deletion_status(
|
|
467
|
+
value: dict[str, Any],
|
|
468
|
+
*,
|
|
469
|
+
content_sha256: Any,
|
|
470
|
+
) -> None:
|
|
471
|
+
expected = {
|
|
472
|
+
"schema_version",
|
|
473
|
+
"content_sha256",
|
|
474
|
+
"status",
|
|
475
|
+
"source_access_allowed",
|
|
476
|
+
"refetch_allowed",
|
|
477
|
+
"replay",
|
|
478
|
+
"removed_at",
|
|
479
|
+
"reason_code",
|
|
480
|
+
"policy_digest",
|
|
481
|
+
"complete_reference_set_digest",
|
|
482
|
+
"references",
|
|
483
|
+
"planned_tombstone_digest",
|
|
484
|
+
}
|
|
485
|
+
if (
|
|
486
|
+
set(value) != expected
|
|
487
|
+
or value["schema_version"] != _DELETION_STATUS_VERSION
|
|
488
|
+
or value["content_sha256"] != content_sha256
|
|
489
|
+
or value["status"] != "deletion-pending"
|
|
490
|
+
or value["source_access_allowed"] is not False
|
|
491
|
+
or value["refetch_allowed"] is not False
|
|
492
|
+
or value["replay"] != "unavailable-by-deletion"
|
|
493
|
+
or not isinstance(value["references"], list)
|
|
494
|
+
):
|
|
495
|
+
raise SourceContractError(
|
|
496
|
+
"DELETION_STATUS",
|
|
497
|
+
"deletion_status",
|
|
498
|
+
"shape or immutable availability state is invalid",
|
|
499
|
+
)
|
|
500
|
+
references: list[DeletedReference] = []
|
|
501
|
+
for item in value["references"]:
|
|
502
|
+
if not isinstance(item, dict) or set(item) != {"reference_kind", "reference_digest"}:
|
|
503
|
+
raise SourceContractError(
|
|
504
|
+
"DELETION_STATUS",
|
|
505
|
+
"deletion_status.references",
|
|
506
|
+
"reference shape is invalid",
|
|
507
|
+
)
|
|
508
|
+
references.append(DeletedReference(**item))
|
|
509
|
+
ordered = _complete_reference_set(tuple(references))
|
|
510
|
+
if (
|
|
511
|
+
canonical_sha256([item.to_dict() for item in ordered])
|
|
512
|
+
!= value["complete_reference_set_digest"]
|
|
513
|
+
):
|
|
514
|
+
raise SourceContractError(
|
|
515
|
+
"DELETION_STATUS",
|
|
516
|
+
"deletion_status.complete_reference_set_digest",
|
|
517
|
+
"does not match the exact reference set",
|
|
518
|
+
)
|
|
519
|
+
planned = DeletionTombstone(
|
|
520
|
+
tombstone_id=f"delete.{value['content_sha256'][:24]}",
|
|
521
|
+
removed_content_sha256=value["content_sha256"],
|
|
522
|
+
removed_at=value["removed_at"],
|
|
523
|
+
reason_code=value["reason_code"],
|
|
524
|
+
policy_digest=value["policy_digest"],
|
|
525
|
+
complete_reference_set_digest=value["complete_reference_set_digest"],
|
|
526
|
+
references=ordered,
|
|
527
|
+
byte_removal_confirmed=True,
|
|
528
|
+
)
|
|
529
|
+
if planned.digest != value["planned_tombstone_digest"]:
|
|
530
|
+
raise SourceContractError(
|
|
531
|
+
"DELETION_STATUS",
|
|
532
|
+
"deletion_status.planned_tombstone_digest",
|
|
533
|
+
"does not bind the exact confirmed tombstone",
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _complete_reference_set(
|
|
538
|
+
references: tuple[DeletedReference, ...],
|
|
539
|
+
) -> tuple[DeletedReference, ...]:
|
|
540
|
+
if not references:
|
|
541
|
+
raise SourceContractError(
|
|
542
|
+
"REFERENCE_SET_EMPTY",
|
|
543
|
+
"references",
|
|
544
|
+
"deletion requires the complete non-empty reference set",
|
|
545
|
+
)
|
|
546
|
+
ordered = tuple(
|
|
547
|
+
sorted(references, key=lambda item: (item.reference_kind, item.reference_digest))
|
|
548
|
+
)
|
|
549
|
+
if ordered != references:
|
|
550
|
+
raise SourceContractError(
|
|
551
|
+
"REFERENCE_SET_ORDER",
|
|
552
|
+
"references",
|
|
553
|
+
"complete reference set must be canonically ordered",
|
|
554
|
+
)
|
|
555
|
+
coordinates = {(item.reference_kind, item.reference_digest) for item in ordered}
|
|
556
|
+
if len(coordinates) != len(ordered):
|
|
557
|
+
raise SourceContractError(
|
|
558
|
+
"REFERENCE_SET_DUPLICATE",
|
|
559
|
+
"references",
|
|
560
|
+
"complete reference set must not contain duplicates",
|
|
561
|
+
)
|
|
562
|
+
return ordered
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _hash_descriptor(descriptor: int) -> str:
|
|
566
|
+
digest = hashlib.sha256()
|
|
567
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
568
|
+
while True:
|
|
569
|
+
chunk = os.read(descriptor, 64 * 1024)
|
|
570
|
+
if not chunk:
|
|
571
|
+
break
|
|
572
|
+
digest.update(chunk)
|
|
573
|
+
return digest.hexdigest()
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _path_digest(path: Path) -> str:
|
|
577
|
+
"""Digest one evidence file through the same bound open its validating reads use.
|
|
578
|
+
|
|
579
|
+
A bare ``Path.read_bytes`` follows links and waits forever on a FIFO, and this digest lands
|
|
580
|
+
in the sealed audit envelope -- so the name is opened exactly as the evidence reads open it,
|
|
581
|
+
and the kind is bound before a byte is hashed.
|
|
582
|
+
"""
|
|
583
|
+
|
|
584
|
+
descriptor = os.open(
|
|
585
|
+
path,
|
|
586
|
+
os.O_RDONLY
|
|
587
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
588
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
589
|
+
| getattr(os, "O_CLOEXEC", 0),
|
|
590
|
+
)
|
|
591
|
+
try:
|
|
592
|
+
_require_readable_evidence_kind(descriptor, path.name)
|
|
593
|
+
return _hash_descriptor(descriptor)
|
|
594
|
+
finally:
|
|
595
|
+
os.close(descriptor)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _seal_keyed_envelope(root: Path, *, content: bytes, index_name: str) -> Path:
|
|
599
|
+
"""Seal immutable bytes and atomically bind one removed-source index to them."""
|
|
600
|
+
|
|
601
|
+
seal_content_addressed_snapshot(root, content=content, suffix="json")
|
|
602
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
603
|
+
root_fd = os.open(root, directory_flags)
|
|
604
|
+
temporary_name = f".stage-index-{os.getpid()}-{threading.get_ident()}-{time.monotonic_ns()}"
|
|
605
|
+
temporary_fd: int | None = None
|
|
606
|
+
try:
|
|
607
|
+
temporary_fd = os.open(
|
|
608
|
+
temporary_name,
|
|
609
|
+
os.O_WRONLY
|
|
610
|
+
| os.O_CREAT
|
|
611
|
+
| os.O_EXCL
|
|
612
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
613
|
+
| getattr(os, "O_CLOEXEC", 0),
|
|
614
|
+
0o600,
|
|
615
|
+
dir_fd=root_fd,
|
|
616
|
+
)
|
|
617
|
+
view = memoryview(content)
|
|
618
|
+
written = 0
|
|
619
|
+
while written < len(view):
|
|
620
|
+
count = os.write(temporary_fd, view[written:])
|
|
621
|
+
if count <= 0:
|
|
622
|
+
raise SourceContractError(
|
|
623
|
+
"DELETION_EVIDENCE_WRITE",
|
|
624
|
+
index_name,
|
|
625
|
+
"removed-source evidence write made no progress",
|
|
626
|
+
)
|
|
627
|
+
written += count
|
|
628
|
+
os.fchmod(temporary_fd, 0o444)
|
|
629
|
+
os.fsync(temporary_fd)
|
|
630
|
+
os.close(temporary_fd)
|
|
631
|
+
temporary_fd = None
|
|
632
|
+
try:
|
|
633
|
+
os.link(
|
|
634
|
+
temporary_name,
|
|
635
|
+
index_name,
|
|
636
|
+
src_dir_fd=root_fd,
|
|
637
|
+
dst_dir_fd=root_fd,
|
|
638
|
+
follow_symlinks=False,
|
|
639
|
+
)
|
|
640
|
+
except FileExistsError:
|
|
641
|
+
existing_fd = os.open(
|
|
642
|
+
index_name,
|
|
643
|
+
os.O_RDONLY
|
|
644
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
645
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
646
|
+
| getattr(os, "O_CLOEXEC", 0),
|
|
647
|
+
dir_fd=root_fd,
|
|
648
|
+
)
|
|
649
|
+
try:
|
|
650
|
+
_require_readable_evidence_kind(existing_fd, index_name)
|
|
651
|
+
existing = _read_exact_descriptor(existing_fd, len(content))
|
|
652
|
+
finally:
|
|
653
|
+
os.close(existing_fd)
|
|
654
|
+
if existing != content:
|
|
655
|
+
raise SourceContractError(
|
|
656
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
657
|
+
index_name,
|
|
658
|
+
"removed-source evidence index already binds different immutable bytes",
|
|
659
|
+
) from None
|
|
660
|
+
os.unlink(temporary_name, dir_fd=root_fd)
|
|
661
|
+
os.fsync(root_fd)
|
|
662
|
+
finally:
|
|
663
|
+
if temporary_fd is not None:
|
|
664
|
+
os.close(temporary_fd)
|
|
665
|
+
try:
|
|
666
|
+
os.unlink(temporary_name, dir_fd=root_fd)
|
|
667
|
+
except FileNotFoundError:
|
|
668
|
+
pass
|
|
669
|
+
os.close(root_fd)
|
|
670
|
+
return root / index_name
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _keyed_envelope_matches(root: Path, *, content: bytes, index_name: str) -> bool:
|
|
674
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
675
|
+
root_fd = os.open(root, directory_flags)
|
|
676
|
+
try:
|
|
677
|
+
try:
|
|
678
|
+
descriptor = os.open(
|
|
679
|
+
index_name,
|
|
680
|
+
os.O_RDONLY
|
|
681
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
682
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
683
|
+
| getattr(os, "O_CLOEXEC", 0),
|
|
684
|
+
dir_fd=root_fd,
|
|
685
|
+
)
|
|
686
|
+
except FileNotFoundError:
|
|
687
|
+
return False
|
|
688
|
+
try:
|
|
689
|
+
_require_readable_evidence_kind(descriptor, index_name)
|
|
690
|
+
existing = _read_exact_descriptor(descriptor, len(content))
|
|
691
|
+
finally:
|
|
692
|
+
os.close(descriptor)
|
|
693
|
+
if existing != content:
|
|
694
|
+
raise SourceContractError(
|
|
695
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
696
|
+
index_name,
|
|
697
|
+
"removed-source evidence index binds different immutable bytes",
|
|
698
|
+
)
|
|
699
|
+
return True
|
|
700
|
+
finally:
|
|
701
|
+
os.close(root_fd)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _require_readable_evidence_kind(descriptor: int, index_name: str) -> None:
|
|
705
|
+
"""Refuse an evidence index that is not a plain file, before a byte of it is read.
|
|
706
|
+
|
|
707
|
+
These descriptors are opened ``O_NONBLOCK``, because opening a FIFO without it waits in the
|
|
708
|
+
kernel for a writer that never comes. The status-envelope open runs while this coordinator
|
|
709
|
+
holds a snapshot name's cross-process lock, where the wait would take every sealer of that
|
|
710
|
+
digest with it; the tombstone and audit opens run after release, where the wait would still
|
|
711
|
+
park this deletion forever. The flag makes the open return; it also means ``read`` can answer
|
|
712
|
+
``EAGAIN`` on anything that is not a regular file, which would leave this module through an
|
|
713
|
+
untyped ``BlockingIOError`` instead of a refusal that names the evidence it declined.
|
|
714
|
+
|
|
715
|
+
So the kind is bound first, exactly as :meth:`DeletionCoordinator._open_validated_snapshot`
|
|
716
|
+
binds it before hashing. ``O_NONBLOCK`` has no effect on ``read`` for a regular file, which is
|
|
717
|
+
the only kind that gets past this line.
|
|
718
|
+
"""
|
|
719
|
+
|
|
720
|
+
info = os.fstat(descriptor)
|
|
721
|
+
if not stat.S_ISREG(info.st_mode):
|
|
722
|
+
raise SourceContractError(
|
|
723
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
724
|
+
index_name,
|
|
725
|
+
f"removed-source evidence index is kind {stat.S_IFMT(info.st_mode):#o},"
|
|
726
|
+
" not a regular file",
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _read_exact_descriptor(descriptor: int, expected_size: int) -> bytes:
|
|
731
|
+
chunks: list[bytes] = []
|
|
732
|
+
total = 0
|
|
733
|
+
while total <= expected_size:
|
|
734
|
+
chunk = os.read(descriptor, min(64 * 1024, expected_size + 1 - total))
|
|
735
|
+
if not chunk:
|
|
736
|
+
break
|
|
737
|
+
chunks.append(chunk)
|
|
738
|
+
total += len(chunk)
|
|
739
|
+
if total != expected_size:
|
|
740
|
+
raise SourceContractError(
|
|
741
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
742
|
+
"evidence",
|
|
743
|
+
"existing evidence length differs from the immutable envelope",
|
|
744
|
+
)
|
|
745
|
+
return b"".join(chunks)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _utc(value: str, path: str) -> datetime:
|
|
749
|
+
if not isinstance(value, str) or not value.endswith("Z"):
|
|
750
|
+
raise SourceContractError("TIMESTAMP", path, "must be an RFC 3339 UTC timestamp")
|
|
751
|
+
try:
|
|
752
|
+
parsed = datetime.fromisoformat(value[:-1] + "+00:00")
|
|
753
|
+
except ValueError:
|
|
754
|
+
raise SourceContractError("TIMESTAMP", path, "must be a valid UTC timestamp") from None
|
|
755
|
+
if parsed.tzinfo != UTC:
|
|
756
|
+
raise SourceContractError("TIMESTAMP", path, "must use UTC")
|
|
757
|
+
return parsed
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _format_utc(value: datetime) -> str:
|
|
761
|
+
return value.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|