mostlyright-data 0.19.4__tar.gz → 0.19.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/PKG-INFO +1 -1
  2. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/pyproject.toml +1 -1
  3. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/skills/mr-data-build/SKILL.md +43 -48
  4. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/router.py +10 -2
  5. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/session.py +3 -0
  6. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_datasets.py +86 -5
  7. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/.gitignore +0 -0
  8. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/README.md +0 -0
  9. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/scripts/hatch_build.py +0 -0
  10. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/skills/mr-data-build/agents/openai.yaml +0 -0
  11. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/skills/mr-data-build/scripts/write_research_notebook.py +0 -0
  12. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/__init__.py +0 -0
  13. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/canonical.py +0 -0
  14. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/formats.py +0 -0
  15. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/hosted_crawler_protocol.py +0 -0
  16. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/key_seam.py +0 -0
  17. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/page_coverage.py +0 -0
  18. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/part_check_evidence.py +0 -0
  19. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/session_probes.py +0 -0
  20. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/skill_assets.py +0 -0
  21. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/table_manifest.py +0 -0
  22. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/__init__.py +0 -0
  23. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/acquire.py +0 -0
  24. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/acquire_cancel.py +0 -0
  25. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/activity.py +0 -0
  26. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/approvals.py +0 -0
  27. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/categories.py +0 -0
  28. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/commands.py +0 -0
  29. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/dataset-categories-v1.json +0 -0
  30. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/download.py +0 -0
  31. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/narrative.py +0 -0
  32. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/parity.py +0 -0
  33. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/probe.py +0 -0
  34. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/progress_vocabulary.py +0 -0
  35. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/propose.py +0 -0
  36. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/recipe.py +0 -0
  37. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/research.py +0 -0
  38. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/runs.py +0 -0
  39. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/stream.py +0 -0
  40. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/stream_venue.py +0 -0
  41. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/transport.py +0 -0
  42. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/user_agent.py +0 -0
  43. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4.py +0 -0
  44. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_artifacts.py +0 -0
  45. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_catalog.py +0 -0
  46. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_connections.py +0 -0
  47. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_dataset_covers.py +0 -0
  48. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_handoff.py +0 -0
  49. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_narrative.py +0 -0
  50. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_query.py +0 -0
  51. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_runs.py +0 -0
  52. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_secrets.py +0 -0
  53. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_stream.py +0 -0
  54. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/v4_tables.py +0 -0
  55. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/thin/vocabulary.py +0 -0
  56. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/__init__.py +0 -0
  57. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/attendance.py +0 -0
  58. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/clarification.py +0 -0
  59. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/cloud_auth.py +0 -0
  60. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/commands/__init__.py +0 -0
  61. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/commands/auth.py +0 -0
  62. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/commands/clarify.py +0 -0
  63. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/commands/login.py +0 -0
  64. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/commands/whoami.py +0 -0
  65. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/credential_native.py +0 -0
  66. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/credential_store.py +0 -0
  67. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/credentials.py +0 -0
  68. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/login.py +0 -0
  69. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/path_kind.py +0 -0
  70. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/plain_file.py +0 -0
  71. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/remediation.py +0 -0
  72. {mostlyright_data-0.19.4 → mostlyright_data-0.19.6}/src/mostlyright/data_harness/ux/render.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mostlyright-data
3
- Version: 0.19.4
3
+ Version: 0.19.6
4
4
  Summary: Mostly Right hosted CLI for reviewed datasets
5
5
  Project-URL: Homepage, https://mostlyright.md/
6
6
  Project-URL: Documentation, https://mostlyright.md/docs/guides/cli/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mostlyright-data"
3
- version = "0.19.4"
3
+ version = "0.19.6"
4
4
  description = "Mostly Right hosted CLI for reviewed datasets"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -155,7 +155,7 @@ gap rather than doing anything, and `export-hosted-candidate`, which is a backen
155
155
  | `mr-data clarify` | Say whether one clarifying question may be put to the user right now, and compose the record of their answer. It reaches nothing, asks nobody and waits for nothing. Exits non-zero when no person can answer or when a recipe is already registered. |
156
156
  | `mr-data probe` | `probe SOURCE_ID QUESTION_ID --dataset DATASET_ID [--kind source_inspect\|sample_rows\|profile_columns\|evaluate_expression]` asks one already-registered source one question and prints the answer. **Do not plan a source inspection around it.** Both positionals are identifiers — `QUESTION_ID` is a question identifier, which no v4 registration receipt returns — and the command rides the frozen `/v3/sessions` routes, which a deployment may have switched off. Read a source instead by registering the recipe and taking an unwindowed sample under `--max-rows`, then `peek`, `query` and `receipt`. |
157
157
  | `mr-data catalog` | `catalog search "QUESTION"` asks the sealed public-source catalogue which of its entries might answer a question and ranks them best first. Every ranked entry comes back with the disposition its own facts earned — `admitted`, `human_escalation_required` or `refused` — because an entry the catalogue could not vouch for is still a finding. `--format csv` states the one data format the question requires — one token per question, never repeated — and it is not a filter: an entry that does not declare that format still comes back ranked, with disposition `refused` and `filters_match` false, so nothing is held back; `--limit N` ranks at most N, up to 25. It fetches nothing and registers nothing. The catalogue holds one provider (Data.gov) and only part of it, so it is never exhaustive and an empty answer is not evidence that no such source exists. When the answer is `catalog_unavailable`, this deployment has no catalogue to search: record the lane and carry on. |
158
- | `mr-data dataset` | Bring the dataset page into existence before there is anything on it, then fill it in while somebody watches. `dataset create --name TEXT` mints it and prints the `dataset_id`; `dataset show ID` reads it back; `dataset set ID --name TEXT --topics "a,b,c" --license ID --description-file F` writes the title, the descriptive tags, the SPDX licence and the description under the version it was read at, retrying once if somebody else wrote first, and an empty `--topics` or `--license` takes that value off the page; `dataset note ID --heading H --blocks-file B` writes one cell of the decision record that OUTLIVES every run, and `--list` reads it back; `dataset watch ID` follows the page's own event stream; `dataset activity ID --phase P --message TEXT` says what is happening right now, silently, and is never a chat message and never a cell; `dataset publish ID [--mode public|link|private]` says who can read the dataset — `public` lists it in the public directory and serves it at an address anybody can read, `link` serves it at an unlisted address, `private` takes it back to the workspace — and `dataset publish ID --show` reads that back without changing it; `dataset archive ID --confirm-name TITLE` retires the page and frees its title, deleting nothing. |
158
+ | `mr-data dataset` | Bring the dataset page into existence before there is anything on it, then fill it in while somebody watches. `dataset create --name TEXT` mints it and prints the `dataset_id`; `dataset show ID` reads it back; `dataset set ID --name TEXT --topics "a,b,c" --license ID --description-file F` writes the title, the descriptive tags, the SPDX licence and the description under the version it was read at, retrying once if somebody else wrote first, and an empty `--topics` or `--license` takes that value off the page; a saved write whose public sync fails exits 2 and reports `public_projection_synced: false` — use `dataset sync ID` to retry that sync without rewriting Studio; `dataset note ID --heading H --blocks-file B` writes one cell of the decision record that OUTLIVES every run, and `--list` reads it back; `dataset watch ID` follows the page's own event stream; `dataset activity ID --phase P --message TEXT` says what is happening right now, silently, and is never a chat message and never a cell; `dataset publish ID [--mode public|link|private]` says who can read the dataset — `public` lists it in the public directory and serves it at an address anybody can read, `link` serves it at an unlisted address, `private` takes it back to the workspace — and `dataset publish ID --show` reads that back without changing it; `dataset archive ID --confirm-name TITLE` retires the page and frees its title, deleting nothing. |
159
159
  | `mr-data recipe` | Register one recipe document — dataset, question, table plan, sources, transform, checks and units — in one call, and print the identifiers the server derived. `mr-data recipe show ID` reads one back. |
160
160
  | `mr-data cover` | Generate and attach one branded 1200×630 dataset cover. Give it the dataset ID and what the image should depict; Studio fixes the model, single-color style, curated random palette, dimensions and storage. |
161
161
  | `mr-data run` | Start one run against a registered recipe, named as `--recipe RECIPE_ID --digest RECIPE_DIGEST` — both required, neither positional, and the digest is the bare hex the registration receipt printed. The mode is one of six: `sample`, `full`, `refresh`, `backfill`, `compact` and `replay`. Four have a shorthand flag (`--sample`, `--full`, `--refresh`, `--backfill`) and two do not, so write `--mode MODE`, which is accepted for every one of them. A refresh narrows each source that declares a supported address window and merges its returned relation into retained source state; unwindowed sources are conditionally revalidated or re-acquired whole, then the full declared transform runs. `--backfill` states the exact window with `--window START END`. `--mode replay --sources-from RUN_ID` asks Studio to run the registered revision against the RETAINED RAW INPUTS of one named successful run of the same table: nothing is fetched from this computer, nothing is acquired again, the result is compared against that run and never becomes the live version, and it neither asks for nor records an approval. Where Studio has replay switched off, or the named run is not successful, not the same table, or no longer retains its inputs, it answers a typed refusal — report the code rather than retrying in another mode. `--sources-from` on any other mode is refused `THIN_ARGUMENT_INVALID` before anything is sent. `--max-rows` and `--max-source-bytes` bound ONE SOURCE rather than the finished table. A large run is held for a spend confirmation, which `--confirm` settles and whose printed `confirm_command` re-states every argument the request carried; a sample-first held full requires Studio's interactive human approval instead. `--cancel RUN` stops one. |
@@ -640,16 +640,16 @@ seconds -- not a paragraph, and not a report. Use exactly this shape, in this or
640
640
  section out only when there is genuinely nothing to say (shown indented here; the document
641
641
  itself carries the headings at column one):
642
642
 
643
- Two or three plain sentences: what the data is about, why it is worth a look, and what is
644
- in it.
643
+ A plain opening paragraph: what the data contains, what question or model it supports, and
644
+ the facts a reader needs before using it.
645
645
 
646
646
  ## Coverage
647
- - **Window:** the exact dates and the time zone.
648
- - **Grain:** one row per what.
649
- - **Cadence:** how often rows arrive, and how many per day or month.
647
+ - Window: the exact dates and the time zone.
648
+ - Grain: one row per what.
649
+ - Cadence: how often rows arrive, and how many per day or month.
650
650
 
651
651
  ## Columns
652
- - `column_name` — what it measures, in which unit.
652
+ - `column_name`: what it measures, in which unit.
653
653
  - (one bullet per column; group unit-sharing columns on one line where that reads better)
654
654
 
655
655
  ## Missing values
@@ -665,43 +665,33 @@ That is six sections: the opening paragraph, and the five headings under it.
665
665
 
666
666
  **Opening paragraph** (everything before the first `##` heading)
667
667
 
668
- Two or three short sentences, 120 to 280 characters, written for somebody who has never heard of
669
- the source, the venue or the format. Sentence one says what the data is about and what makes it
670
- worth a look, in at most 110 characters. The rest says, in the same plain words, what is in it.
671
-
672
- 1. No tickers, station codes, series codes, format names, publisher names, row definitions (the
673
- sentence that begins `Each row is`) or row counts here. Every one of those facts is in the
674
- sections below, which is where a reader who wants it will look.
675
- 2. No Markdown: no bold, no links, no bullets, no code.
676
- 3. A hook is an interesting fact stated plainly, never a pitch. Banned outright: comprehensive,
677
- rich, valuable, powerful, unlock, explore, dive into, perfect for, insights, "this dataset", a
678
- rhetorical question, an exclamation mark.
679
- 4. Every number in it must be one the run established. "About every half hour" and "thousands of
680
- markets" are claims, and a claim that is not on the run does not go here.
681
- 5. The stranger test, before anything else is written: read sentence one alone under the title. If
682
- a curious non-specialist could not say what the data is about and would not want the next line,
683
- rewrite it.
684
- 6. Plain and short. Every sentence under about twenty words, every sentence a fact: the venue, the
685
- subject, the horizon, the cadence, the window. Nothing about what a reader might do with it: no
686
- "so you can", "a way to", "ready to", "for seeing". Say what is there and stop.
687
-
688
- One airport weather table, the shape to avoid and the shape to write. To avoid:
689
-
690
- Each row is one METAR or SPECI weather report from Beijing (ZBAA), since January 2020, via
691
- Iowa Environmental Mesonet. 117,876 reports of temperature, wind, pressure, visibility with
692
- the day's highest and lowest reading.
693
-
694
- To write:
695
-
696
- Beijing airport weather since 2020, a report about every half hour, with the day's high and
697
- low on each row. Temperature, wind, pressure and visibility as the airport recorded them.
698
-
699
- The station code, the report types, the publisher and the count all moved down to Coverage and to
700
- Source and rights, where a reader who wants them looks. "About every half hour" stays only because
701
- Coverage puts a month at 1,302 to 1,591 rows, which is one row every 27 to 33 minutes. Three ways
702
- to fail: the pitch (`Unlock real-time insights into how prediction markets price
703
- the weather!`), the promise (`ready to test a forecast against what actually happened`) and the
704
- bare subject with no reason to care (`Beijing weather data.`).
668
+ Write as an engineer explaining a useful table to another engineer. The opening may have more than
669
+ two sentences. Start with the concrete records the dataset contains. Then say which question the
670
+ records help answer or which model they can support. Include geography, time range, row grain,
671
+ update cadence and material limitations when those facts are known. Put a join, transformation or
672
+ source caveat here only when it changes how somebody should use the data.
673
+
674
+ 1. The first two sentences must distinguish this dataset from every other dataset in the set. If
675
+ both sentences could describe another dataset unchanged, rewrite them.
676
+ 2. Use ordinary verbs such as contains, tracks, joins, updates, records and returns. Vary sentence
677
+ length, and use short sentences for the important facts.
678
+ 3. Preserve every established fact. Never invent coverage, freshness, quality, licensing or an
679
+ intended use.
680
+ 4. Write `Mostly Right`, with a space, except inside a literal identifier that uses another form.
681
+ 5. Use no Markdown in the opening paragraph. Do not use generic openings such as `This dataset
682
+ provides`, marketing claims such as `comprehensive`, `powerful` or `high-quality`, or abstract
683
+ phrases such as `enables insights`, `facilitates analysis` or `serves as a valuable resource`.
684
+ 6. Do not explain page design or metadata fields. Do not use em dashes, semicolons, bold labels or
685
+ fake quotations. Explain the data before implementation details.
686
+
687
+ After the build establishes each fact, an airport-weather opening can read:
688
+
689
+ Hourly weather observations from 20 airport stations across the United States. Each row records
690
+ one station report with temperature, dew point, wind, visibility, and observation time. Use it
691
+ to compare observed conditions across stations and time or train a short-term weather model.
692
+ The table keeps multiple reports within an hour when conditions change, so aggregate it before
693
+ using one row per station-hour. Mostly Right refreshes the recent window and preserves older
694
+ observations.
705
695
 
706
696
  **Body rules** (the five sections under the headings)
707
697
  1. Every sentence must stay true and complete when quoted alone, away from the page. Never
@@ -715,21 +705,26 @@ bare subject with no reason to care (`Beijing weather data.`).
715
705
  5. Everything in the paragraph below still applies.
716
706
 
717
707
  Rules that keep it readable: short sentences; no sentence over about twenty-five words; bullets
718
- rather than comma lists; no tables, no code blocks, no links to internal tools, no headings beyond
708
+ rather than comma lists; no bold labels, em dashes, semicolons or fake quotations; no tables, no
709
+ code blocks, no links to internal tools, no headings beyond
719
710
  the ones above. Spell out the names a reader would search for -- the place, the identifiers in
720
711
  every common form, the programme and the publisher -- once each, under Coverage and under
721
712
  Source and rights, and never as a keyword list. A one-line description is a defect to fix in the
722
713
  revision, not a style choice. Give `table.description` the grain and the window.
714
+ Every statement in the opening and body must preserve an established fact. Never add unsupported
715
+ coverage, freshness, quality, licensing or intended use.
723
716
 
724
717
  **Check before writing.** Fix and re-check until every line passes:
725
718
  - title: at most 60 characters, contains the place, contains no colon, pipe or exclamation mark,
726
719
  no brand, no "dataset" or "data", no version number, no run date
727
- - opening paragraph: two or three sentences, each under about twenty words, 120 to 280 characters,
728
- first full stop before character 110, no Markdown, no identifier, publisher name, row count or
729
- row definition, no banned word, no clause about what a reader might do
720
+ - opening paragraph: at least two distinct sentences, with the concrete records first and the
721
+ question or model they support next; include known geography, time range, grain, update cadence
722
+ and material limitations; no Markdown, banned style or invented fact
730
723
  - category: exactly one fixed ID saved and verified before recipe registration
731
724
  - topics: 3 to 8 descriptive tags, no duplicates, lowercase, at most 40 characters each
732
725
  - licence: an SPDX identifier the sources actually grant, or `--license` left off
726
+ - whole description: every statement preserves an established fact; no bold labels, em dashes,
727
+ semicolons, fake quotations or unsupported claims
733
728
  - every `##` heading in DESCRIPTION.md is one of the five, in that relative order, none repeated
734
729
 
735
730
  **Every dataset gets a cover before its first run is presented.** Once the description is
@@ -461,7 +461,7 @@ def _exit_codes() -> dict[str, Callable[[dict[str, Any]], int]]:
461
461
  ``ux.commands.CommandSpec`` gives: an exit code that can disagree with the printed answer is
462
462
  an exit code somebody will trust over the answer.
463
463
 
464
- Eight entries, and all eight are answers a script acts on rather than reports it reads: a run
464
+ These are answers a script acts on rather than reports it reads: a run
465
465
  held at the spend gate has not started, a run that failed is not a build whichever surface it
466
466
  was learned from, a table whose declared checks did not all pass is not one to go on with, a
467
467
  question this command stopped waiting on has not been answered, and a probe that settled
@@ -476,7 +476,14 @@ def _exit_codes() -> dict[str, Callable[[dict[str, Any]], int]]:
476
476
  """
477
477
 
478
478
  from mostlyright.data_harness.thin import probe as probe_module
479
- from mostlyright.data_harness.thin import v4_artifacts, v4_query, v4_runs, v4_stream, v4_tables
479
+ from mostlyright.data_harness.thin import (
480
+ v4_artifacts,
481
+ v4_datasets,
482
+ v4_query,
483
+ v4_runs,
484
+ v4_stream,
485
+ v4_tables,
486
+ )
480
487
 
481
488
  return {
482
489
  "run": v4_runs.run_exit_code,
@@ -488,6 +495,7 @@ def _exit_codes() -> dict[str, Callable[[dict[str, Any]], int]]:
488
495
  "probe": probe_module.probe_exit_code,
489
496
  "table": v4_tables.lifecycle_exit_code,
490
497
  "demote": v4_tables.lifecycle_exit_code,
498
+ "dataset": v4_datasets.dataset_exit_code,
491
499
  }
492
500
 
493
501
 
@@ -63,6 +63,9 @@ CLOUD_SESSION_HANDOFF_PATH = "/api/cli/session-handoffs"
63
63
  #: this client sends a mode and renders what came back rather than deciding any part of it.
64
64
  CLOUD_DATASET_ACCESS_PATH = "/api/cli/datasets/{dataset_id}/access"
65
65
 
66
+ # Projection repair after a Studio metadata write, without a publication mutation.
67
+ CLOUD_DATASET_IDENTITY_PATH = "/api/cli/datasets/{dataset_id}/identity"
68
+
66
69
  #: The one Cloud address a Dataset has. ``mostlyright-cloud`` serves it at this route with
67
70
  #: section suffixes, and a run has no page of its own: its evidence lives on this page's Harness
68
71
  #: card, so a receipt names its run with :func:`run_dashboard_url` as a query member here rather
@@ -117,6 +117,7 @@ from mostlyright.data_harness.thin.narrative import (
117
117
  from mostlyright.data_harness.thin.runs import new_idempotency_key
118
118
  from mostlyright.data_harness.thin.session import (
119
119
  CLOUD_DATASET_ACCESS_PATH,
120
+ CLOUD_DATASET_IDENTITY_PATH,
120
121
  CLOUD_REFUSAL_CODE,
121
122
  MAX_STATED_MESSAGE_CHARS,
122
123
  CloudClient,
@@ -152,7 +153,7 @@ from mostlyright.data_harness.thin.v4_narrative import (
152
153
  refuse,
153
154
  v4_cell_receipt,
154
155
  )
155
- from mostlyright.data_harness.ux.credentials import resolve_cloud_credentials
156
+ from mostlyright.data_harness.ux.credentials import LoginError, resolve_cloud_credentials
156
157
  from mostlyright.data_harness.ux.plain_file import PlainFileRefusal, read_plain_file
157
158
 
158
159
  CREATE_SCHEMA = f"{THIN_SCHEMA_PREFIX}-v4-dataset-created.v1"
@@ -191,6 +192,7 @@ ACTIONS: tuple[str, ...] = (
191
192
  "categories",
192
193
  "create",
193
194
  "set",
195
+ "sync",
194
196
  "draft",
195
197
  "show",
196
198
  "state",
@@ -205,6 +207,7 @@ ACTIONS: tuple[str, ...] = (
205
207
  #: what produces one.
206
208
  _NEEDS_DATASET: tuple[str, ...] = (
207
209
  "set",
210
+ "sync",
208
211
  "draft",
209
212
  "show",
210
213
  "state",
@@ -353,13 +356,23 @@ CLOUD_COMPOSED_REFUSAL = "step_up_required"
353
356
 
354
357
 
355
358
  class CloudDatasetAccessClient(CloudClient):
356
- """The one Cloud route this command speaks, over the shared device-key Cloud client.
359
+ """Dataset access and identity routes over the shared device-key Cloud client.
357
360
 
358
361
  Adds two routes and no transport, for the reason :class:`StudioV4DatasetClient` gives about
359
362
  its own: a second ``call`` would be a second place for the origin check, the byte ceiling, the
360
363
  credential header and the refusal folding to drift.
361
364
  """
362
365
 
366
+ def sync_dataset_identity(self, dataset_id: str) -> dict[str, Any]:
367
+ """Carry Studio's current metadata into Cloud's existing public projection."""
368
+
369
+ return self.call(
370
+ "POST",
371
+ CLOUD_DATASET_IDENTITY_PATH.format(dataset_id=dataset_id),
372
+ act="update the dataset's public page",
373
+ body={},
374
+ )
375
+
363
376
  def dataset_access(self, dataset_id: str) -> dict[str, Any]:
364
377
  """Report how this dataset is shared now, changing nothing."""
365
378
 
@@ -551,7 +564,8 @@ def declare_arguments(parser: argparse.ArgumentParser) -> None:
551
564
  "Bring a dataset page into existence before there is anything on it, then fill it in "
552
565
  "while somebody watches. `create` mints the page from a working title; `show` reads it "
553
566
  "back; `set` writes the final title, the topics, the licence and the description, "
554
- "conditional on the version it was read at; `note` writes one cell of the decision "
567
+ "conditional on the version it was read at; `sync` retries only public-page sync; "
568
+ "`note` writes one cell of the decision "
555
569
  "record that outlives every run; `activity` says in one line what is happening right "
556
570
  "now, which the page shows while it happens and forgets afterwards; `watch` follows the "
557
571
  "page's own event stream; `publish` says who can read the dataset -- anybody, whoever "
@@ -1148,6 +1162,49 @@ def draft(
1148
1162
  }
1149
1163
 
1150
1164
 
1165
+ def _sync_written_identity(dataset_id: str, cloud_url: str) -> dict[str, Any]:
1166
+ """Report partial success honestly: a saved Studio write must not be lost in a sync error."""
1167
+
1168
+ try:
1169
+ credentials = resolve_cloud_credentials()
1170
+ require_device_key(credentials)
1171
+ cloud = CloudDatasetAccessClient(credentials)
1172
+ if cloud.cloud_url != cloud_url.rstrip("/"):
1173
+ raise ThinLaneError(
1174
+ "THIN_CLOUD_ORIGIN_CHANGED", "Cloud origin changed during the write"
1175
+ )
1176
+ answer = cloud.sync_dataset_identity(dataset_id)
1177
+ if (
1178
+ answer.get("schema_version") != "mostlyright-dataset-identity-sync.v1"
1179
+ or answer.get("status") != "synced"
1180
+ or answer.get("dataset_id") != dataset_id
1181
+ or "dataset_version" not in answer
1182
+ or (
1183
+ answer["dataset_version"] is not None
1184
+ and (type(answer["dataset_version"]) is not int or answer["dataset_version"] < 1)
1185
+ )
1186
+ or "dataset_slug" not in answer
1187
+ or (answer["dataset_slug"] is not None and not isinstance(answer["dataset_slug"], str))
1188
+ ):
1189
+ raise ThinLaneError(
1190
+ "THIN_IDENTITY_SYNC_INVALID", "Cloud did not confirm the projection"
1191
+ )
1192
+ return {"public_projection_synced": True}
1193
+ except (ThinLaneError, LoginError, OSError) as error:
1194
+ return {
1195
+ "public_projection_synced": False,
1196
+ "public_projection_error": (
1197
+ error.code
1198
+ if isinstance(error, ThinLaneError) and getattr(error, "http_status", None) != 404
1199
+ else "THIN_IDENTITY_SYNC_UNAVAILABLE"
1200
+ ),
1201
+ "public_projection_note": (
1202
+ "The dataset was saved, but its public page could not be updated. "
1203
+ f"Retry only the sync with: mr-data dataset sync {dataset_id}"
1204
+ ),
1205
+ }
1206
+
1207
+
1151
1208
  def write(
1152
1209
  args: argparse.Namespace,
1153
1210
  *,
@@ -1242,8 +1299,14 @@ def write(
1242
1299
  f"{version}; somebody else is editing it. Read it with mr-data dataset show "
1243
1300
  f"{dataset_id} and write again",
1244
1301
  ) from second
1302
+ # Validate the write receipt before making a second request with another credential.
1303
+ written_id = _dataset_id(updated, verb="write")
1304
+ if written_id != dataset_id:
1305
+ raise ThinLaneError("THIN_DATASET_COORDINATE_MISMATCH", "Studio returned another dataset")
1306
+ projection = _sync_written_identity(dataset_id, selected.session.cloud_url)
1245
1307
  return {
1246
1308
  "schema_version": SET_SCHEMA,
1309
+ **projection,
1247
1310
  "status": "dataset_updated",
1248
1311
  "lane": "hosted",
1249
1312
  "dataset_id": _dataset_id(updated, verb="write"),
@@ -1598,6 +1661,7 @@ def note(
1598
1661
  #: belongs to both; that is one verb short of the whole truth and still points at the pair of
1599
1662
  #: commands the flag is for, which is what somebody who mistyped needs.
1600
1663
  _VERB_FLAGS: Mapping[str, tuple[tuple[str, str], ...]] = {
1664
+ "sync": (),
1601
1665
  "set": (("name", "--name"), ("description_file", "--description-file")),
1602
1666
  "note": (
1603
1667
  ("heading", "--heading"),
@@ -1923,6 +1987,18 @@ def dataset(
1923
1987
  return draft(args, client=client, stdin=stdin)
1924
1988
  if action == "set":
1925
1989
  return write(args, client=client, stdin=stdin)
1990
+ if action == "sync":
1991
+ dataset_id = identifier(args.dataset_id, "dataset identifier")
1992
+ credentials = resolve_cloud_credentials()
1993
+ result = _sync_written_identity(dataset_id, credentials.cloud_url)
1994
+ return {
1995
+ "schema_version": "mostlyright-dataset-identity-reconciled.v1",
1996
+ "status": "dataset_identity_synced"
1997
+ if result["public_projection_synced"]
1998
+ else "dataset_identity_sync_pending",
1999
+ "dataset_id": dataset_id,
2000
+ **result,
2001
+ }
1926
2002
  if action == "note":
1927
2003
  return note(args, client=client, stdin=stdin)
1928
2004
  if action == "activity":
@@ -1930,8 +2006,7 @@ def dataset(
1930
2006
  if action == "watch":
1931
2007
  return watch(args, client=client)
1932
2008
  if action == "publish":
1933
- # ⚠ THE ONE VERB WHOSE CLIENT IS CLOUD'S. Everything else on this surface speaks Studio's
1934
- # dataset routes; who may read a dataset is Cloud's decision and Cloud's route, so the
2009
+ # This verb and identity sync use Cloud credentials. Access is Cloud's decision, so the
1935
2010
  # injected client for this verb is the Cloud one and cannot be a Studio session.
1936
2011
  if client is not None and not isinstance(client, CloudDatasetAccessClient):
1937
2012
  raise ThinLaneError(
@@ -1946,6 +2021,11 @@ def dataset(
1946
2021
  )
1947
2022
 
1948
2023
 
2024
+ def dataset_exit_code(payload: dict[str, Any]) -> int:
2025
+ """Preserve the saved-write receipt but signal an incomplete public sync."""
2026
+ return 2 if payload.get("public_projection_synced") is False else 0
2027
+
2028
+
1949
2029
  __all__ = [
1950
2030
  "ACCESS_MEMBERS",
1951
2031
  "ACCESS_REFUSALS",
@@ -1973,6 +2053,7 @@ __all__ = [
1973
2053
  "checked_name",
1974
2054
  "create",
1975
2055
  "dataset",
2056
+ "dataset_exit_code",
1976
2057
  "declare_arguments",
1977
2058
  "event_stream",
1978
2059
  "note",