mostlyright-data 0.19.4__tar.gz → 0.19.5__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.5}/PKG-INFO +1 -1
  2. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/pyproject.toml +1 -1
  3. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/skills/mr-data-build/SKILL.md +1 -1
  4. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/router.py +10 -2
  5. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/session.py +3 -0
  6. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_datasets.py +86 -5
  7. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/.gitignore +0 -0
  8. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/README.md +0 -0
  9. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/scripts/hatch_build.py +0 -0
  10. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/skills/mr-data-build/agents/openai.yaml +0 -0
  11. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/skills/mr-data-build/scripts/write_research_notebook.py +0 -0
  12. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/__init__.py +0 -0
  13. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/canonical.py +0 -0
  14. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/formats.py +0 -0
  15. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/hosted_crawler_protocol.py +0 -0
  16. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/key_seam.py +0 -0
  17. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/page_coverage.py +0 -0
  18. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/part_check_evidence.py +0 -0
  19. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/session_probes.py +0 -0
  20. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/skill_assets.py +0 -0
  21. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/table_manifest.py +0 -0
  22. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/__init__.py +0 -0
  23. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/acquire.py +0 -0
  24. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/acquire_cancel.py +0 -0
  25. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/activity.py +0 -0
  26. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/approvals.py +0 -0
  27. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/categories.py +0 -0
  28. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/commands.py +0 -0
  29. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/dataset-categories-v1.json +0 -0
  30. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/download.py +0 -0
  31. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/narrative.py +0 -0
  32. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/parity.py +0 -0
  33. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/probe.py +0 -0
  34. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/progress_vocabulary.py +0 -0
  35. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/propose.py +0 -0
  36. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/recipe.py +0 -0
  37. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/research.py +0 -0
  38. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/runs.py +0 -0
  39. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/stream.py +0 -0
  40. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/stream_venue.py +0 -0
  41. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/transport.py +0 -0
  42. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/user_agent.py +0 -0
  43. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4.py +0 -0
  44. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_artifacts.py +0 -0
  45. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_catalog.py +0 -0
  46. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_connections.py +0 -0
  47. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_dataset_covers.py +0 -0
  48. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_handoff.py +0 -0
  49. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_narrative.py +0 -0
  50. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_query.py +0 -0
  51. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_runs.py +0 -0
  52. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_secrets.py +0 -0
  53. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_stream.py +0 -0
  54. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/v4_tables.py +0 -0
  55. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/thin/vocabulary.py +0 -0
  56. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/__init__.py +0 -0
  57. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/attendance.py +0 -0
  58. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/clarification.py +0 -0
  59. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/cloud_auth.py +0 -0
  60. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/commands/__init__.py +0 -0
  61. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/commands/auth.py +0 -0
  62. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/commands/clarify.py +0 -0
  63. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/commands/login.py +0 -0
  64. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/commands/whoami.py +0 -0
  65. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/credential_native.py +0 -0
  66. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/credential_store.py +0 -0
  67. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/credentials.py +0 -0
  68. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/login.py +0 -0
  69. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/path_kind.py +0 -0
  70. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/plain_file.py +0 -0
  71. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/src/mostlyright/data_harness/ux/remediation.py +0 -0
  72. {mostlyright_data-0.19.4 → mostlyright_data-0.19.5}/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.5
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.5"
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. |
@@ -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",