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,872 @@
|
|
|
1
|
+
"""Closed contracts for immutable, bounded repair and closure review."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives import serialization
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
12
|
+
|
|
13
|
+
from mostlyright.data_harness.canonical import canonical_sha256
|
|
14
|
+
from mostlyright.data_harness.preparation.contracts import (
|
|
15
|
+
QUALITY_CHECK_POLICY,
|
|
16
|
+
AsOfJoinSpec,
|
|
17
|
+
EqualityJoinSpec,
|
|
18
|
+
OperationSpec,
|
|
19
|
+
)
|
|
20
|
+
from mostlyright.data_harness.repair.adapters import (
|
|
21
|
+
JoinSpec,
|
|
22
|
+
preparation_value,
|
|
23
|
+
)
|
|
24
|
+
from mostlyright.data_harness.repair.errors import RepairError
|
|
25
|
+
|
|
26
|
+
REPAIR_CONTRACT_VERSION = "repair.v1"
|
|
27
|
+
REPAIR_ASSIGNMENT_VERSION = "repair-assignment.v1"
|
|
28
|
+
REPAIR_SUCCESSOR_VERSION = "repair-successor.v1"
|
|
29
|
+
CYCLE_ANCHOR_VERSION = "repair-cycle-anchor.v1"
|
|
30
|
+
CLOSURE_SCOPE_VERSION = "repair-closure-scope.v1"
|
|
31
|
+
CLOSURE_REPORT_VERSION = "repair-closure-report.v1"
|
|
32
|
+
CLOSURE_DECISION_VERSION = "repair-closure-decision.v1"
|
|
33
|
+
MAX_FAILED_CANDIDATES = 3
|
|
34
|
+
MAX_REPAIR_ACTIONS = 32
|
|
35
|
+
MAX_FINDINGS = 256
|
|
36
|
+
|
|
37
|
+
BLOCKING_SEVERITIES = frozenset({"blocker", "critical", "high"})
|
|
38
|
+
SEVERITIES = frozenset({*BLOCKING_SEVERITIES, "medium", "low"})
|
|
39
|
+
REPAIR_ACTION_KINDS = frozenset(
|
|
40
|
+
{
|
|
41
|
+
"append_operation",
|
|
42
|
+
"replace_operation",
|
|
43
|
+
"remove_operation",
|
|
44
|
+
"append_join",
|
|
45
|
+
"replace_join",
|
|
46
|
+
"remove_join",
|
|
47
|
+
"replace_output_columns",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
# Repair contracts require canonical identifier order, while quality reports retain the
|
|
51
|
+
# policy-owned presentation order.
|
|
52
|
+
ALL_QUALITY_CHECK_IDS = tuple(sorted(QUALITY_CHECK_POLICY))
|
|
53
|
+
REQUIRED_CLOSURE_LENSES = ("data_quality_temporal", "repair_correctness")
|
|
54
|
+
|
|
55
|
+
_IDENT = re.compile(r"\A[A-Za-z][A-Za-z0-9_.-]{0,127}\Z")
|
|
56
|
+
_DIGEST = re.compile(r"\A[0-9a-f]{64}\Z")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _identifier(value: Any, path: str) -> str:
|
|
60
|
+
if not isinstance(value, str) or _IDENT.fullmatch(value) is None:
|
|
61
|
+
raise RepairError("REPAIR_IDENTIFIER", path, "must be a canonical identifier")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _digest(value: Any, path: str) -> str:
|
|
66
|
+
if not isinstance(value, str) or _DIGEST.fullmatch(value) is None:
|
|
67
|
+
raise RepairError("REPAIR_DIGEST", path, "must be a lowercase SHA-256 digest")
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _text(value: Any, path: str, *, maximum: int = 4_096) -> str:
|
|
72
|
+
if not isinstance(value, str) or not value or len(value) > maximum:
|
|
73
|
+
raise RepairError("REPAIR_TEXT", path, f"must be 1..{maximum} characters")
|
|
74
|
+
if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
|
|
75
|
+
raise RepairError("REPAIR_UNICODE", path, "contains an unpaired surrogate")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _identifiers(
|
|
80
|
+
value: Any,
|
|
81
|
+
path: str,
|
|
82
|
+
*,
|
|
83
|
+
minimum: int = 1,
|
|
84
|
+
allowed: frozenset[str] | None = None,
|
|
85
|
+
) -> tuple[str, ...]:
|
|
86
|
+
if not isinstance(value, tuple) or not minimum <= len(value) <= 256:
|
|
87
|
+
raise RepairError(
|
|
88
|
+
"REPAIR_COLLECTION",
|
|
89
|
+
path,
|
|
90
|
+
f"must be an immutable tuple with {minimum}..256 members",
|
|
91
|
+
)
|
|
92
|
+
result = tuple(_identifier(item, f"{path}[{index}]") for index, item in enumerate(value))
|
|
93
|
+
if result != tuple(sorted(set(result))):
|
|
94
|
+
raise RepairError("REPAIR_ORDER", path, "must be unique and canonically sorted")
|
|
95
|
+
if allowed is not None and any(item not in allowed for item in result):
|
|
96
|
+
raise RepairError("REPAIR_VALUE", path, "contains an unsupported identifier")
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _public_key(value: Any, path: str) -> Ed25519PublicKey:
|
|
101
|
+
if not isinstance(value, str):
|
|
102
|
+
raise RepairError("REPAIR_PUBLIC_KEY", path, "must be base64 text")
|
|
103
|
+
try:
|
|
104
|
+
raw = base64.b64decode(value, validate=True)
|
|
105
|
+
return Ed25519PublicKey.from_public_bytes(raw)
|
|
106
|
+
except (ValueError, TypeError) as exc:
|
|
107
|
+
raise RepairError("REPAIR_PUBLIC_KEY", path, "is not an Ed25519 public key") from exc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def public_key_text(public_key: Ed25519PublicKey) -> str:
|
|
111
|
+
raw = public_key.public_bytes(
|
|
112
|
+
serialization.Encoding.Raw,
|
|
113
|
+
serialization.PublicFormat.Raw,
|
|
114
|
+
)
|
|
115
|
+
return base64.b64encode(raw).decode("ascii")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_public_key(value: str) -> Ed25519PublicKey:
|
|
119
|
+
"""Load one enrolled public key without exposing private material."""
|
|
120
|
+
|
|
121
|
+
return _public_key(value, "public_key")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class CandidateReference:
|
|
126
|
+
"""Exact immutable candidate state known to the trusted coordinator."""
|
|
127
|
+
|
|
128
|
+
candidate_digest: str
|
|
129
|
+
plan_digest: str
|
|
130
|
+
validation_policy_digest: str
|
|
131
|
+
input_set_digest: str
|
|
132
|
+
evidence_digest: str
|
|
133
|
+
producer_principal: str
|
|
134
|
+
producer_attempt: str
|
|
135
|
+
status: str
|
|
136
|
+
failed_candidate_count: int
|
|
137
|
+
|
|
138
|
+
def __post_init__(self) -> None:
|
|
139
|
+
for name in (
|
|
140
|
+
"candidate_digest",
|
|
141
|
+
"plan_digest",
|
|
142
|
+
"validation_policy_digest",
|
|
143
|
+
"input_set_digest",
|
|
144
|
+
"evidence_digest",
|
|
145
|
+
):
|
|
146
|
+
_digest(getattr(self, name), f"candidate.{name}")
|
|
147
|
+
_identifier(self.producer_principal, "candidate.producer_principal")
|
|
148
|
+
_identifier(self.producer_attempt, "candidate.producer_attempt")
|
|
149
|
+
if self.status not in {
|
|
150
|
+
"fixes_required",
|
|
151
|
+
"human_direction_required",
|
|
152
|
+
"pending_independent_review",
|
|
153
|
+
"reviewed_successor",
|
|
154
|
+
}:
|
|
155
|
+
raise RepairError("REPAIR_STATUS", "candidate.status", "is unsupported")
|
|
156
|
+
if (
|
|
157
|
+
type(self.failed_candidate_count) is not int
|
|
158
|
+
or not 0 <= self.failed_candidate_count <= MAX_FAILED_CANDIDATES
|
|
159
|
+
):
|
|
160
|
+
raise RepairError(
|
|
161
|
+
"REPAIR_COUNTER",
|
|
162
|
+
"candidate.failed_candidate_count",
|
|
163
|
+
"must be between zero and three",
|
|
164
|
+
)
|
|
165
|
+
if self.status == "fixes_required" and self.failed_candidate_count not in {1, 2}:
|
|
166
|
+
raise RepairError(
|
|
167
|
+
"REPAIR_COUNTER",
|
|
168
|
+
"candidate.failed_candidate_count",
|
|
169
|
+
"fixes_required candidates must have failed count one or two",
|
|
170
|
+
)
|
|
171
|
+
if (
|
|
172
|
+
self.status == "human_direction_required"
|
|
173
|
+
and self.failed_candidate_count != MAX_FAILED_CANDIDATES
|
|
174
|
+
):
|
|
175
|
+
raise RepairError(
|
|
176
|
+
"REPAIR_COUNTER",
|
|
177
|
+
"candidate.failed_candidate_count",
|
|
178
|
+
"human direction requires exactly three failed candidates",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def to_dict(self) -> dict[str, Any]:
|
|
182
|
+
return preparation_value(self)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class RepairFinding:
|
|
187
|
+
"""One stable finding; closure is evidence-derived, never a caller Boolean."""
|
|
188
|
+
|
|
189
|
+
finding_id: str
|
|
190
|
+
severity: str
|
|
191
|
+
invariant: str
|
|
192
|
+
evidence_digest: str
|
|
193
|
+
affected_artifact_digest: str
|
|
194
|
+
acceptance_condition: str
|
|
195
|
+
affected_check_ids: tuple[str, ...]
|
|
196
|
+
reviewer_principal: str
|
|
197
|
+
|
|
198
|
+
def __post_init__(self) -> None:
|
|
199
|
+
_identifier(self.finding_id, "finding.finding_id")
|
|
200
|
+
if self.severity not in SEVERITIES:
|
|
201
|
+
raise RepairError("REPAIR_SEVERITY", "finding.severity", "is unsupported")
|
|
202
|
+
_text(self.invariant, "finding.invariant")
|
|
203
|
+
_digest(self.evidence_digest, "finding.evidence_digest")
|
|
204
|
+
_digest(self.affected_artifact_digest, "finding.affected_artifact_digest")
|
|
205
|
+
_text(self.acceptance_condition, "finding.acceptance_condition")
|
|
206
|
+
_identifiers(
|
|
207
|
+
self.affected_check_ids,
|
|
208
|
+
"finding.affected_check_ids",
|
|
209
|
+
allowed=frozenset(ALL_QUALITY_CHECK_IDS),
|
|
210
|
+
)
|
|
211
|
+
_identifier(self.reviewer_principal, "finding.reviewer_principal")
|
|
212
|
+
|
|
213
|
+
def to_dict(self) -> dict[str, Any]:
|
|
214
|
+
return preparation_value(self)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class FindingInventory:
|
|
219
|
+
blocking: tuple[RepairFinding, ...]
|
|
220
|
+
backlog: tuple[RepairFinding, ...]
|
|
221
|
+
|
|
222
|
+
def __post_init__(self) -> None:
|
|
223
|
+
if not isinstance(self.blocking, tuple) or not 1 <= len(self.blocking) <= MAX_FINDINGS:
|
|
224
|
+
raise RepairError(
|
|
225
|
+
"REPAIR_FINDINGS",
|
|
226
|
+
"inventory.blocking",
|
|
227
|
+
"must contain 1..256 findings",
|
|
228
|
+
)
|
|
229
|
+
if not isinstance(self.backlog, tuple) or len(self.backlog) > MAX_FINDINGS:
|
|
230
|
+
raise RepairError(
|
|
231
|
+
"REPAIR_FINDINGS",
|
|
232
|
+
"inventory.backlog",
|
|
233
|
+
"must contain at most 256 findings",
|
|
234
|
+
)
|
|
235
|
+
if any(item.severity not in BLOCKING_SEVERITIES for item in self.blocking):
|
|
236
|
+
raise RepairError(
|
|
237
|
+
"REPAIR_FINDINGS",
|
|
238
|
+
"inventory.blocking",
|
|
239
|
+
"contains a non-blocking severity",
|
|
240
|
+
)
|
|
241
|
+
if any(item.severity in BLOCKING_SEVERITIES for item in self.backlog):
|
|
242
|
+
raise RepairError(
|
|
243
|
+
"REPAIR_FINDINGS",
|
|
244
|
+
"inventory.backlog",
|
|
245
|
+
"contains a blocking severity",
|
|
246
|
+
)
|
|
247
|
+
blocking_ids = tuple(item.finding_id for item in self.blocking)
|
|
248
|
+
backlog_ids = tuple(item.finding_id for item in self.backlog)
|
|
249
|
+
if blocking_ids != tuple(sorted(set(blocking_ids))) or backlog_ids != tuple(
|
|
250
|
+
sorted(set(backlog_ids))
|
|
251
|
+
):
|
|
252
|
+
raise RepairError(
|
|
253
|
+
"REPAIR_ORDER",
|
|
254
|
+
"inventory",
|
|
255
|
+
"finding partitions must be canonically sorted",
|
|
256
|
+
)
|
|
257
|
+
if set(blocking_ids) & set(backlog_ids):
|
|
258
|
+
raise RepairError(
|
|
259
|
+
"REPAIR_FINDINGS",
|
|
260
|
+
"inventory",
|
|
261
|
+
"finding IDs must be globally unique",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def digest(self) -> str:
|
|
266
|
+
return canonical_sha256(self.to_dict())
|
|
267
|
+
|
|
268
|
+
def to_dict(self) -> dict[str, Any]:
|
|
269
|
+
return {
|
|
270
|
+
"blocking": [item.to_dict() for item in self.blocking],
|
|
271
|
+
"backlog": [item.to_dict() for item in self.backlog],
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass(frozen=True)
|
|
276
|
+
class RepairAction:
|
|
277
|
+
"""One operation from the closed repair-plan edit registry."""
|
|
278
|
+
|
|
279
|
+
action_id: str
|
|
280
|
+
kind: str
|
|
281
|
+
target_id: str
|
|
282
|
+
expected_before_digest: str | None
|
|
283
|
+
replacement: OperationSpec | JoinSpec | tuple[str, ...] | None
|
|
284
|
+
|
|
285
|
+
def __post_init__(self) -> None:
|
|
286
|
+
_identifier(self.action_id, "action.action_id")
|
|
287
|
+
if self.kind not in REPAIR_ACTION_KINDS:
|
|
288
|
+
raise RepairError("REPAIR_ACTION_KIND", "action.kind", "is not allowlisted")
|
|
289
|
+
_identifier(self.target_id, "action.target_id")
|
|
290
|
+
append = self.kind in {"append_operation", "append_join"}
|
|
291
|
+
if append != (self.expected_before_digest is None):
|
|
292
|
+
raise RepairError(
|
|
293
|
+
"REPAIR_ACTION_SHAPE",
|
|
294
|
+
"action.expected_before_digest",
|
|
295
|
+
"must be absent only for append actions",
|
|
296
|
+
)
|
|
297
|
+
if self.expected_before_digest is not None:
|
|
298
|
+
_digest(self.expected_before_digest, "action.expected_before_digest")
|
|
299
|
+
if self.kind in {"append_operation", "replace_operation"}:
|
|
300
|
+
if not isinstance(self.replacement, OperationSpec):
|
|
301
|
+
raise RepairError(
|
|
302
|
+
"REPAIR_ACTION_SHAPE",
|
|
303
|
+
"action.replacement",
|
|
304
|
+
"must be an OperationSpec",
|
|
305
|
+
)
|
|
306
|
+
if self.replacement.operation_id != self.target_id:
|
|
307
|
+
raise RepairError(
|
|
308
|
+
"REPAIR_ACTION_SHAPE",
|
|
309
|
+
"action.target_id",
|
|
310
|
+
"must equal the replacement operation ID",
|
|
311
|
+
)
|
|
312
|
+
elif self.kind == "remove_operation":
|
|
313
|
+
if self.replacement is not None:
|
|
314
|
+
raise RepairError(
|
|
315
|
+
"REPAIR_ACTION_SHAPE",
|
|
316
|
+
"action.replacement",
|
|
317
|
+
"must be absent for removal",
|
|
318
|
+
)
|
|
319
|
+
elif self.kind in {"append_join", "replace_join"}:
|
|
320
|
+
if not isinstance(self.replacement, (EqualityJoinSpec, AsOfJoinSpec)):
|
|
321
|
+
raise RepairError(
|
|
322
|
+
"REPAIR_ACTION_SHAPE",
|
|
323
|
+
"action.replacement",
|
|
324
|
+
"must be a supported join",
|
|
325
|
+
)
|
|
326
|
+
if self.replacement.join_id != self.target_id:
|
|
327
|
+
raise RepairError(
|
|
328
|
+
"REPAIR_ACTION_SHAPE",
|
|
329
|
+
"action.target_id",
|
|
330
|
+
"must equal the replacement join ID",
|
|
331
|
+
)
|
|
332
|
+
elif self.kind == "remove_join":
|
|
333
|
+
if self.replacement is not None:
|
|
334
|
+
raise RepairError(
|
|
335
|
+
"REPAIR_ACTION_SHAPE",
|
|
336
|
+
"action.replacement",
|
|
337
|
+
"must be absent for removal",
|
|
338
|
+
)
|
|
339
|
+
elif self.kind == "replace_output_columns":
|
|
340
|
+
if not isinstance(self.replacement, tuple):
|
|
341
|
+
raise RepairError(
|
|
342
|
+
"REPAIR_ACTION_SHAPE",
|
|
343
|
+
"action.replacement",
|
|
344
|
+
"must be an immutable tuple",
|
|
345
|
+
)
|
|
346
|
+
values = tuple(
|
|
347
|
+
_identifier(item, f"action.replacement[{index}]")
|
|
348
|
+
for index, item in enumerate(self.replacement)
|
|
349
|
+
)
|
|
350
|
+
if not values or len(values) != len(set(values)):
|
|
351
|
+
raise RepairError(
|
|
352
|
+
"REPAIR_ACTION_SHAPE",
|
|
353
|
+
"action.replacement",
|
|
354
|
+
"must contain unique ordered output columns",
|
|
355
|
+
)
|
|
356
|
+
if self.target_id != "output_columns":
|
|
357
|
+
raise RepairError(
|
|
358
|
+
"REPAIR_ACTION_SHAPE",
|
|
359
|
+
"action.target_id",
|
|
360
|
+
"must be output_columns for an output replacement",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def to_dict(self) -> dict[str, Any]:
|
|
364
|
+
return preparation_value(self)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@dataclass(frozen=True)
|
|
368
|
+
class CycleAnchor:
|
|
369
|
+
"""Externally signed monotonic coordinator state."""
|
|
370
|
+
|
|
371
|
+
schema_version: str
|
|
372
|
+
run_id: str
|
|
373
|
+
root_candidate_digest: str
|
|
374
|
+
current_candidate_digest: str
|
|
375
|
+
current_state_digest: str
|
|
376
|
+
inventory_digest: str
|
|
377
|
+
failed_candidate_count: int
|
|
378
|
+
sequence: int
|
|
379
|
+
predecessor_anchor_digest: str | None
|
|
380
|
+
authority_key_id: str
|
|
381
|
+
authority_public_key: str
|
|
382
|
+
signature: str
|
|
383
|
+
|
|
384
|
+
def __post_init__(self) -> None:
|
|
385
|
+
if self.schema_version != CYCLE_ANCHOR_VERSION:
|
|
386
|
+
raise RepairError("REPAIR_ANCHOR_VERSION", "anchor.schema_version", "is unsupported")
|
|
387
|
+
_identifier(self.run_id, "anchor.run_id")
|
|
388
|
+
for name in (
|
|
389
|
+
"root_candidate_digest",
|
|
390
|
+
"current_candidate_digest",
|
|
391
|
+
"current_state_digest",
|
|
392
|
+
"inventory_digest",
|
|
393
|
+
):
|
|
394
|
+
_digest(getattr(self, name), f"anchor.{name}")
|
|
395
|
+
if (
|
|
396
|
+
type(self.failed_candidate_count) is not int
|
|
397
|
+
or not 0 <= self.failed_candidate_count <= MAX_FAILED_CANDIDATES
|
|
398
|
+
):
|
|
399
|
+
raise RepairError("REPAIR_COUNTER", "anchor.failed_candidate_count", "is invalid")
|
|
400
|
+
if type(self.sequence) is not int or self.sequence < 0:
|
|
401
|
+
raise RepairError("REPAIR_COUNTER", "anchor.sequence", "is invalid")
|
|
402
|
+
if self.predecessor_anchor_digest is not None:
|
|
403
|
+
_digest(self.predecessor_anchor_digest, "anchor.predecessor_anchor_digest")
|
|
404
|
+
if (self.sequence == 0) != (self.predecessor_anchor_digest is None):
|
|
405
|
+
raise RepairError(
|
|
406
|
+
"REPAIR_ANCHOR_CHAIN",
|
|
407
|
+
"anchor.predecessor_anchor_digest",
|
|
408
|
+
"must be absent only for sequence zero",
|
|
409
|
+
)
|
|
410
|
+
_identifier(self.authority_key_id, "anchor.authority_key_id")
|
|
411
|
+
_public_key(self.authority_public_key, "anchor.authority_public_key")
|
|
412
|
+
if not isinstance(self.signature, str):
|
|
413
|
+
raise RepairError("REPAIR_SIGNATURE", "anchor.signature", "must be base64 text")
|
|
414
|
+
try:
|
|
415
|
+
base64.b64decode(self.signature, validate=True)
|
|
416
|
+
except (ValueError, TypeError) as exc:
|
|
417
|
+
raise RepairError(
|
|
418
|
+
"REPAIR_SIGNATURE",
|
|
419
|
+
"anchor.signature",
|
|
420
|
+
"is not canonical base64",
|
|
421
|
+
) from exc
|
|
422
|
+
|
|
423
|
+
def unsigned_dict(self) -> dict[str, Any]:
|
|
424
|
+
value = preparation_value(self)
|
|
425
|
+
del value["signature"]
|
|
426
|
+
return value
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def digest(self) -> str:
|
|
430
|
+
return canonical_sha256(preparation_value(self))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@dataclass(frozen=True)
|
|
434
|
+
class RepairAssignment:
|
|
435
|
+
schema_version: str
|
|
436
|
+
assignment_id: str
|
|
437
|
+
run_id: str
|
|
438
|
+
predecessor: CandidateReference
|
|
439
|
+
inventory: FindingInventory
|
|
440
|
+
anchor_digest: str
|
|
441
|
+
anchor_sequence: int
|
|
442
|
+
fixer_principal: str
|
|
443
|
+
fixer_attempt: str
|
|
444
|
+
actions: tuple[RepairAction, ...]
|
|
445
|
+
affected_check_ids: tuple[str, ...]
|
|
446
|
+
|
|
447
|
+
def __post_init__(self) -> None:
|
|
448
|
+
if self.schema_version != REPAIR_ASSIGNMENT_VERSION:
|
|
449
|
+
raise RepairError(
|
|
450
|
+
"REPAIR_ASSIGNMENT_VERSION",
|
|
451
|
+
"assignment.schema_version",
|
|
452
|
+
"is unsupported",
|
|
453
|
+
)
|
|
454
|
+
_identifier(self.assignment_id, "assignment.assignment_id")
|
|
455
|
+
_identifier(self.run_id, "assignment.run_id")
|
|
456
|
+
if self.predecessor.status != "fixes_required":
|
|
457
|
+
raise RepairError(
|
|
458
|
+
"REPAIR_LIFECYCLE",
|
|
459
|
+
"assignment.predecessor",
|
|
460
|
+
"must be a fixes_required candidate",
|
|
461
|
+
)
|
|
462
|
+
_digest(self.anchor_digest, "assignment.anchor_digest")
|
|
463
|
+
if type(self.anchor_sequence) is not int or self.anchor_sequence < 0:
|
|
464
|
+
raise RepairError("REPAIR_COUNTER", "assignment.anchor_sequence", "is invalid")
|
|
465
|
+
_identifier(self.fixer_principal, "assignment.fixer_principal")
|
|
466
|
+
_identifier(self.fixer_attempt, "assignment.fixer_attempt")
|
|
467
|
+
if self.fixer_principal == self.predecessor.producer_principal:
|
|
468
|
+
raise RepairError(
|
|
469
|
+
"REPAIR_IDENTITY",
|
|
470
|
+
"assignment.fixer_principal",
|
|
471
|
+
"fixer must differ from the predecessor producer",
|
|
472
|
+
)
|
|
473
|
+
if self.fixer_attempt == self.predecessor.producer_attempt:
|
|
474
|
+
raise RepairError(
|
|
475
|
+
"REPAIR_IDENTITY",
|
|
476
|
+
"assignment.fixer_attempt",
|
|
477
|
+
"fixer attempt must differ from the predecessor producer attempt",
|
|
478
|
+
)
|
|
479
|
+
if not isinstance(self.actions, tuple) or not 1 <= len(self.actions) <= MAX_REPAIR_ACTIONS:
|
|
480
|
+
raise RepairError(
|
|
481
|
+
"REPAIR_ACTIONS",
|
|
482
|
+
"assignment.actions",
|
|
483
|
+
"must contain 1..32 actions",
|
|
484
|
+
)
|
|
485
|
+
action_ids = tuple(item.action_id for item in self.actions)
|
|
486
|
+
if len(set(action_ids)) != len(action_ids):
|
|
487
|
+
raise RepairError(
|
|
488
|
+
"REPAIR_ACTIONS",
|
|
489
|
+
"assignment.actions",
|
|
490
|
+
"contains duplicate action IDs",
|
|
491
|
+
)
|
|
492
|
+
# An assignment must claim the entire check inventory, not the subset a repair
|
|
493
|
+
# believes it touches: a fixer that under-declares would narrow the re-validation
|
|
494
|
+
# that closes the repair. A genuine subset is therefore rejected here, so
|
|
495
|
+
# ``affected_check_ids`` on an assignment is in practice always
|
|
496
|
+
# ``ALL_QUALITY_CHECK_IDS``. Per-finding subsets do exist and are validated on
|
|
497
|
+
# ``RepairFinding.affected_check_ids`` instead.
|
|
498
|
+
checked = _identifiers(
|
|
499
|
+
self.affected_check_ids,
|
|
500
|
+
"assignment.affected_check_ids",
|
|
501
|
+
allowed=frozenset(ALL_QUALITY_CHECK_IDS),
|
|
502
|
+
)
|
|
503
|
+
if checked != ALL_QUALITY_CHECK_IDS:
|
|
504
|
+
raise RepairError(
|
|
505
|
+
"REPAIR_CHECK_COVERAGE",
|
|
506
|
+
"assignment.affected_check_ids",
|
|
507
|
+
"must include every directly or transitively affected quality check",
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def digest(self) -> str:
|
|
512
|
+
return canonical_sha256(self.to_dict())
|
|
513
|
+
|
|
514
|
+
def to_dict(self) -> dict[str, Any]:
|
|
515
|
+
return preparation_value(self)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@dataclass(frozen=True)
|
|
519
|
+
class ResolutionEvidence:
|
|
520
|
+
finding_id: str
|
|
521
|
+
predecessor_evidence_digest: str
|
|
522
|
+
acceptance_condition_digest: str
|
|
523
|
+
check_evidence: tuple[tuple[str, str], ...]
|
|
524
|
+
|
|
525
|
+
def __post_init__(self) -> None:
|
|
526
|
+
_identifier(self.finding_id, "resolution.finding_id")
|
|
527
|
+
_digest(self.predecessor_evidence_digest, "resolution.predecessor_evidence_digest")
|
|
528
|
+
_digest(self.acceptance_condition_digest, "resolution.acceptance_condition_digest")
|
|
529
|
+
if not isinstance(self.check_evidence, tuple) or not self.check_evidence:
|
|
530
|
+
raise RepairError(
|
|
531
|
+
"REPAIR_RESOLUTION",
|
|
532
|
+
"resolution.check_evidence",
|
|
533
|
+
"must contain deterministic check evidence",
|
|
534
|
+
)
|
|
535
|
+
check_ids = tuple(item[0] for item in self.check_evidence)
|
|
536
|
+
if check_ids != tuple(sorted(set(check_ids))):
|
|
537
|
+
raise RepairError(
|
|
538
|
+
"REPAIR_ORDER",
|
|
539
|
+
"resolution.check_evidence",
|
|
540
|
+
"must be unique and sorted",
|
|
541
|
+
)
|
|
542
|
+
for check_id, evidence_digest in self.check_evidence:
|
|
543
|
+
_identifier(check_id, "resolution.check_evidence.check_id")
|
|
544
|
+
_digest(evidence_digest, "resolution.check_evidence.digest")
|
|
545
|
+
|
|
546
|
+
@property
|
|
547
|
+
def digest(self) -> str:
|
|
548
|
+
return canonical_sha256(preparation_value(self))
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@dataclass(frozen=True)
|
|
552
|
+
class RepairSuccessor:
|
|
553
|
+
schema_version: str
|
|
554
|
+
assignment_digest: str
|
|
555
|
+
predecessor_candidate_digest: str
|
|
556
|
+
candidate: CandidateReference
|
|
557
|
+
cycle_number: int
|
|
558
|
+
fixer_principal: str
|
|
559
|
+
fixer_attempt: str
|
|
560
|
+
affected_check_ids: tuple[str, ...]
|
|
561
|
+
check_evidence: tuple[tuple[str, str, str], ...]
|
|
562
|
+
resolution_evidence: tuple[ResolutionEvidence, ...]
|
|
563
|
+
carried_backlog: tuple[RepairFinding, ...]
|
|
564
|
+
|
|
565
|
+
def __post_init__(self) -> None:
|
|
566
|
+
if self.schema_version != REPAIR_SUCCESSOR_VERSION:
|
|
567
|
+
raise RepairError(
|
|
568
|
+
"REPAIR_SUCCESSOR_VERSION",
|
|
569
|
+
"successor.schema_version",
|
|
570
|
+
"is unsupported",
|
|
571
|
+
)
|
|
572
|
+
_digest(self.assignment_digest, "successor.assignment_digest")
|
|
573
|
+
_digest(self.predecessor_candidate_digest, "successor.predecessor_candidate_digest")
|
|
574
|
+
if self.candidate.candidate_digest == self.predecessor_candidate_digest:
|
|
575
|
+
raise RepairError(
|
|
576
|
+
"REPAIR_IMMUTABILITY",
|
|
577
|
+
"successor.candidate",
|
|
578
|
+
"must have a distinct candidate identity",
|
|
579
|
+
)
|
|
580
|
+
if type(self.cycle_number) is not int or not 1 <= self.cycle_number <= 3:
|
|
581
|
+
raise RepairError("REPAIR_COUNTER", "successor.cycle_number", "is invalid")
|
|
582
|
+
_identifier(self.fixer_principal, "successor.fixer_principal")
|
|
583
|
+
_identifier(self.fixer_attempt, "successor.fixer_attempt")
|
|
584
|
+
_identifiers(
|
|
585
|
+
self.affected_check_ids,
|
|
586
|
+
"successor.affected_check_ids",
|
|
587
|
+
allowed=frozenset(ALL_QUALITY_CHECK_IDS),
|
|
588
|
+
)
|
|
589
|
+
check_ids = tuple(item[0] for item in self.check_evidence)
|
|
590
|
+
if check_ids != ALL_QUALITY_CHECK_IDS:
|
|
591
|
+
raise RepairError(
|
|
592
|
+
"REPAIR_CHECK_COVERAGE",
|
|
593
|
+
"successor.check_evidence",
|
|
594
|
+
"must contain the complete rerun check inventory",
|
|
595
|
+
)
|
|
596
|
+
for check_id, status, evidence_digest in self.check_evidence:
|
|
597
|
+
_identifier(check_id, "successor.check_evidence.check_id")
|
|
598
|
+
if status not in {"passed", "failed"}:
|
|
599
|
+
raise RepairError(
|
|
600
|
+
"REPAIR_STATUS",
|
|
601
|
+
"successor.check_evidence.status",
|
|
602
|
+
"is unsupported",
|
|
603
|
+
)
|
|
604
|
+
_digest(evidence_digest, "successor.check_evidence.digest")
|
|
605
|
+
resolution_ids = tuple(item.finding_id for item in self.resolution_evidence)
|
|
606
|
+
if resolution_ids != tuple(sorted(set(resolution_ids))):
|
|
607
|
+
raise RepairError(
|
|
608
|
+
"REPAIR_ORDER",
|
|
609
|
+
"successor.resolution_evidence",
|
|
610
|
+
"must be unique and sorted",
|
|
611
|
+
)
|
|
612
|
+
backlog_ids = tuple(item.finding_id for item in self.carried_backlog)
|
|
613
|
+
if backlog_ids != tuple(sorted(set(backlog_ids))):
|
|
614
|
+
raise RepairError(
|
|
615
|
+
"REPAIR_ORDER",
|
|
616
|
+
"successor.carried_backlog",
|
|
617
|
+
"must be unique and sorted",
|
|
618
|
+
)
|
|
619
|
+
if any(item.severity in BLOCKING_SEVERITIES for item in self.carried_backlog):
|
|
620
|
+
raise RepairError(
|
|
621
|
+
"REPAIR_BACKLOG",
|
|
622
|
+
"successor.carried_backlog",
|
|
623
|
+
"cannot contain a blocking finding",
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
@property
|
|
627
|
+
def resolution_evidence_digest(self) -> str:
|
|
628
|
+
return canonical_sha256([preparation_value(item) for item in self.resolution_evidence])
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@dataclass(frozen=True)
|
|
632
|
+
class ReviewerEnrollment:
|
|
633
|
+
lens: str
|
|
634
|
+
principal_id: str
|
|
635
|
+
attempt_id: str
|
|
636
|
+
key_id: str
|
|
637
|
+
public_key: str
|
|
638
|
+
|
|
639
|
+
def __post_init__(self) -> None:
|
|
640
|
+
if self.lens not in REQUIRED_CLOSURE_LENSES:
|
|
641
|
+
raise RepairError("REPAIR_REVIEW_LENS", "reviewer.lens", "is unsupported")
|
|
642
|
+
_identifier(self.principal_id, "reviewer.principal_id")
|
|
643
|
+
_identifier(self.attempt_id, "reviewer.attempt_id")
|
|
644
|
+
_identifier(self.key_id, "reviewer.key_id")
|
|
645
|
+
_public_key(self.public_key, "reviewer.public_key")
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
@dataclass(frozen=True)
|
|
649
|
+
class ClosureReviewScope:
|
|
650
|
+
schema_version: str
|
|
651
|
+
scope_id: str
|
|
652
|
+
run_id: str
|
|
653
|
+
assignment_digest: str
|
|
654
|
+
predecessor_candidate_digest: str
|
|
655
|
+
successor_candidate_digest: str
|
|
656
|
+
successor_evidence_digest: str
|
|
657
|
+
resolution_evidence_digest: str
|
|
658
|
+
cycle_number: int
|
|
659
|
+
blocking_finding_ids: tuple[str, ...]
|
|
660
|
+
backlog_digest: str
|
|
661
|
+
reviewers: tuple[ReviewerEnrollment, ...]
|
|
662
|
+
|
|
663
|
+
def __post_init__(self) -> None:
|
|
664
|
+
if self.schema_version != CLOSURE_SCOPE_VERSION:
|
|
665
|
+
raise RepairError("REPAIR_SCOPE_VERSION", "scope.schema_version", "is unsupported")
|
|
666
|
+
_identifier(self.scope_id, "scope.scope_id")
|
|
667
|
+
_identifier(self.run_id, "scope.run_id")
|
|
668
|
+
for name in (
|
|
669
|
+
"assignment_digest",
|
|
670
|
+
"predecessor_candidate_digest",
|
|
671
|
+
"successor_candidate_digest",
|
|
672
|
+
"successor_evidence_digest",
|
|
673
|
+
"resolution_evidence_digest",
|
|
674
|
+
"backlog_digest",
|
|
675
|
+
):
|
|
676
|
+
_digest(getattr(self, name), f"scope.{name}")
|
|
677
|
+
if self.predecessor_candidate_digest == self.successor_candidate_digest:
|
|
678
|
+
raise RepairError(
|
|
679
|
+
"REPAIR_IMMUTABILITY",
|
|
680
|
+
"scope.successor_candidate_digest",
|
|
681
|
+
"must differ from the predecessor",
|
|
682
|
+
)
|
|
683
|
+
if type(self.cycle_number) is not int or not 1 <= self.cycle_number <= 3:
|
|
684
|
+
raise RepairError("REPAIR_COUNTER", "scope.cycle_number", "is invalid")
|
|
685
|
+
_identifiers(self.blocking_finding_ids, "scope.blocking_finding_ids")
|
|
686
|
+
if not isinstance(self.reviewers, tuple) or len(self.reviewers) != 2:
|
|
687
|
+
raise RepairError(
|
|
688
|
+
"REPAIR_REVIEWERS",
|
|
689
|
+
"scope.reviewers",
|
|
690
|
+
"must contain exactly two reviewers",
|
|
691
|
+
)
|
|
692
|
+
if tuple(item.lens for item in self.reviewers) != REQUIRED_CLOSURE_LENSES:
|
|
693
|
+
raise RepairError(
|
|
694
|
+
"REPAIR_REVIEWERS",
|
|
695
|
+
"scope.reviewers",
|
|
696
|
+
"must cover both required lenses in canonical order",
|
|
697
|
+
)
|
|
698
|
+
for attribute in ("principal_id", "attempt_id", "key_id", "public_key"):
|
|
699
|
+
values = tuple(getattr(item, attribute) for item in self.reviewers)
|
|
700
|
+
if len(set(values)) != len(values):
|
|
701
|
+
raise RepairError(
|
|
702
|
+
"REPAIR_REVIEWERS",
|
|
703
|
+
f"scope.reviewers.{attribute}",
|
|
704
|
+
"must be distinct",
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
@property
|
|
708
|
+
def digest(self) -> str:
|
|
709
|
+
return canonical_sha256(preparation_value(self))
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@dataclass(frozen=True)
|
|
713
|
+
class ClosureReviewReport:
|
|
714
|
+
schema_version: str
|
|
715
|
+
scope_digest: str
|
|
716
|
+
successor_candidate_digest: str
|
|
717
|
+
successor_evidence_digest: str
|
|
718
|
+
reviewer_principal: str
|
|
719
|
+
reviewer_attempt: str
|
|
720
|
+
reviewer_key_id: str
|
|
721
|
+
lens: str
|
|
722
|
+
decision: str
|
|
723
|
+
verified_resolutions: tuple[tuple[str, str], ...]
|
|
724
|
+
findings: tuple[RepairFinding, ...]
|
|
725
|
+
signature: str
|
|
726
|
+
|
|
727
|
+
def __post_init__(self) -> None:
|
|
728
|
+
if self.schema_version != CLOSURE_REPORT_VERSION:
|
|
729
|
+
raise RepairError("REPAIR_REPORT_VERSION", "report.schema_version", "is unsupported")
|
|
730
|
+
_digest(self.scope_digest, "report.scope_digest")
|
|
731
|
+
_digest(self.successor_candidate_digest, "report.successor_candidate_digest")
|
|
732
|
+
_digest(self.successor_evidence_digest, "report.successor_evidence_digest")
|
|
733
|
+
_identifier(self.reviewer_principal, "report.reviewer_principal")
|
|
734
|
+
_identifier(self.reviewer_attempt, "report.reviewer_attempt")
|
|
735
|
+
_identifier(self.reviewer_key_id, "report.reviewer_key_id")
|
|
736
|
+
if self.lens not in REQUIRED_CLOSURE_LENSES:
|
|
737
|
+
raise RepairError("REPAIR_REVIEW_LENS", "report.lens", "is unsupported")
|
|
738
|
+
if self.decision not in {"accept", "reject"}:
|
|
739
|
+
raise RepairError("REPAIR_REVIEW_DECISION", "report.decision", "is unsupported")
|
|
740
|
+
ids = tuple(item[0] for item in self.verified_resolutions)
|
|
741
|
+
if ids != tuple(sorted(set(ids))):
|
|
742
|
+
raise RepairError(
|
|
743
|
+
"REPAIR_ORDER",
|
|
744
|
+
"report.verified_resolutions",
|
|
745
|
+
"must be unique and sorted",
|
|
746
|
+
)
|
|
747
|
+
for finding_id, resolution_digest in self.verified_resolutions:
|
|
748
|
+
_identifier(finding_id, "report.verified_resolutions.finding_id")
|
|
749
|
+
_digest(resolution_digest, "report.verified_resolutions.digest")
|
|
750
|
+
finding_ids = tuple(item.finding_id for item in self.findings)
|
|
751
|
+
if finding_ids != tuple(sorted(set(finding_ids))):
|
|
752
|
+
raise RepairError(
|
|
753
|
+
"REPAIR_ORDER",
|
|
754
|
+
"report.findings",
|
|
755
|
+
"must be unique and sorted",
|
|
756
|
+
)
|
|
757
|
+
if not isinstance(self.signature, str):
|
|
758
|
+
raise RepairError("REPAIR_SIGNATURE", "report.signature", "must be base64 text")
|
|
759
|
+
try:
|
|
760
|
+
base64.b64decode(self.signature, validate=True)
|
|
761
|
+
except (ValueError, TypeError) as exc:
|
|
762
|
+
raise RepairError(
|
|
763
|
+
"REPAIR_SIGNATURE",
|
|
764
|
+
"report.signature",
|
|
765
|
+
"is not canonical base64",
|
|
766
|
+
) from exc
|
|
767
|
+
|
|
768
|
+
def unsigned_dict(self) -> dict[str, Any]:
|
|
769
|
+
value = preparation_value(self)
|
|
770
|
+
del value["signature"]
|
|
771
|
+
return value
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
@dataclass(frozen=True)
|
|
775
|
+
class ClosureDecision:
|
|
776
|
+
schema_version: str
|
|
777
|
+
scope_digest: str
|
|
778
|
+
candidate: CandidateReference
|
|
779
|
+
status: str
|
|
780
|
+
report_digests: tuple[str, ...]
|
|
781
|
+
preserved_backlog: tuple[RepairFinding, ...]
|
|
782
|
+
remaining_blocking: tuple[RepairFinding, ...]
|
|
783
|
+
|
|
784
|
+
def __post_init__(self) -> None:
|
|
785
|
+
if self.schema_version != CLOSURE_DECISION_VERSION:
|
|
786
|
+
raise RepairError(
|
|
787
|
+
"REPAIR_DECISION_VERSION",
|
|
788
|
+
"decision.schema_version",
|
|
789
|
+
"is unsupported",
|
|
790
|
+
)
|
|
791
|
+
_digest(self.scope_digest, "decision.scope_digest")
|
|
792
|
+
if self.status not in {
|
|
793
|
+
"reviewed_successor",
|
|
794
|
+
"fixes_required",
|
|
795
|
+
"human_direction_required",
|
|
796
|
+
}:
|
|
797
|
+
raise RepairError("REPAIR_STATUS", "decision.status", "is unsupported")
|
|
798
|
+
if not isinstance(self.report_digests, tuple) or len(self.report_digests) != 2:
|
|
799
|
+
raise RepairError(
|
|
800
|
+
"REPAIR_REPORTS",
|
|
801
|
+
"decision.report_digests",
|
|
802
|
+
"must contain two report digests",
|
|
803
|
+
)
|
|
804
|
+
for digest in self.report_digests:
|
|
805
|
+
_digest(digest, "decision.report_digests")
|
|
806
|
+
if self.status == "reviewed_successor" and self.remaining_blocking:
|
|
807
|
+
raise RepairError(
|
|
808
|
+
"REPAIR_CLOSURE",
|
|
809
|
+
"decision.remaining_blocking",
|
|
810
|
+
"a reviewed successor cannot retain blocking findings",
|
|
811
|
+
)
|
|
812
|
+
if self.status != self.candidate.status:
|
|
813
|
+
raise RepairError(
|
|
814
|
+
"REPAIR_STATUS",
|
|
815
|
+
"decision.candidate.status",
|
|
816
|
+
"must match the derived decision status",
|
|
817
|
+
)
|
|
818
|
+
backlog_ids = tuple(item.finding_id for item in self.preserved_backlog)
|
|
819
|
+
blocking_ids = tuple(item.finding_id for item in self.remaining_blocking)
|
|
820
|
+
if backlog_ids != tuple(sorted(set(backlog_ids))) or blocking_ids != tuple(
|
|
821
|
+
sorted(set(blocking_ids))
|
|
822
|
+
):
|
|
823
|
+
raise RepairError(
|
|
824
|
+
"REPAIR_ORDER",
|
|
825
|
+
"decision",
|
|
826
|
+
"finding partitions must be unique and sorted",
|
|
827
|
+
)
|
|
828
|
+
if any(item.severity in BLOCKING_SEVERITIES for item in self.preserved_backlog):
|
|
829
|
+
raise RepairError(
|
|
830
|
+
"REPAIR_BACKLOG",
|
|
831
|
+
"decision.preserved_backlog",
|
|
832
|
+
"cannot contain a blocking finding",
|
|
833
|
+
)
|
|
834
|
+
if any(item.severity not in BLOCKING_SEVERITIES for item in self.remaining_blocking):
|
|
835
|
+
raise RepairError(
|
|
836
|
+
"REPAIR_FINDINGS",
|
|
837
|
+
"decision.remaining_blocking",
|
|
838
|
+
"cannot contain a non-blocking finding",
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
@property
|
|
842
|
+
def digest(self) -> str:
|
|
843
|
+
return canonical_sha256(preparation_value(self))
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
__all__ = [
|
|
847
|
+
"ALL_QUALITY_CHECK_IDS",
|
|
848
|
+
"BLOCKING_SEVERITIES",
|
|
849
|
+
"CLOSURE_DECISION_VERSION",
|
|
850
|
+
"CLOSURE_REPORT_VERSION",
|
|
851
|
+
"CLOSURE_SCOPE_VERSION",
|
|
852
|
+
"CYCLE_ANCHOR_VERSION",
|
|
853
|
+
"MAX_FAILED_CANDIDATES",
|
|
854
|
+
"REPAIR_ASSIGNMENT_VERSION",
|
|
855
|
+
"REPAIR_CONTRACT_VERSION",
|
|
856
|
+
"REPAIR_SUCCESSOR_VERSION",
|
|
857
|
+
"REQUIRED_CLOSURE_LENSES",
|
|
858
|
+
"CandidateReference",
|
|
859
|
+
"ClosureDecision",
|
|
860
|
+
"ClosureReviewReport",
|
|
861
|
+
"ClosureReviewScope",
|
|
862
|
+
"CycleAnchor",
|
|
863
|
+
"FindingInventory",
|
|
864
|
+
"RepairAction",
|
|
865
|
+
"RepairAssignment",
|
|
866
|
+
"RepairFinding",
|
|
867
|
+
"RepairSuccessor",
|
|
868
|
+
"ResolutionEvidence",
|
|
869
|
+
"ReviewerEnrollment",
|
|
870
|
+
"load_public_key",
|
|
871
|
+
"public_key_text",
|
|
872
|
+
]
|