mostlyright-data 0.31.13__tar.gz → 0.31.14__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 (139) hide show
  1. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/PKG-INFO +1 -1
  2. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/commands.md +1 -1
  3. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/patterns.md +3 -2
  4. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/readers.md +12 -2
  5. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/sources.md +4 -0
  6. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/recipe.py +2 -2
  7. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/session.py +25 -0
  8. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/support.py +32 -8
  9. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/table_search_contract.json +8 -2
  10. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4.py +2 -2
  11. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_datasets.py +19 -6
  12. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_query.py +93 -6
  13. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_reader.py +26 -0
  14. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_search.py +3 -1
  15. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/remediation.py +12 -1
  16. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/.gitignore +0 -0
  17. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/README.md +0 -0
  18. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/pyproject.toml +0 -0
  19. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/scripts/hatch_build.py +0 -0
  20. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/SKILL.md +0 -0
  21. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/agents/openai.yaml +0 -0
  22. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/1-open-the-page-and-the-link-to-it-in-the-first-message.md +0 -0
  23. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/2-brief-two-to-four-questions-each-with-a-recommended-answer.md +0 -0
  24. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/3-probe-read-a-source-before-committing-to-it.md +0 -0
  25. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/4-decide-say-what-you-chose-what-you-refused-and-ask-one-question.md +0 -0
  26. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/5-draft-one-recipe-document-one-call.md +0 -0
  27. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/6-build-one-run-sized-to-acquire-every-measured-source-whole.md +0 -0
  28. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/7-interrogate-ask-the-run-what-it-actually-delivered.md +0 -0
  29. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/8-fix-revise-the-document-and-register-it-again.md +0 -0
  30. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/9-present-only-what-survived-inspection-with-caveats.md +0 -0
  31. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/agent-protocol.md +0 -0
  32. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/autonomous-delivery.md +0 -0
  33. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/before-the-first-tool-call.md +0 -0
  34. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/boundaries.md +0 -0
  35. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/cloud-authentication-preflight.md +0 -0
  36. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/cross-repository-protocol-reference.md +0 -0
  37. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/enriching-with-small-models.md +0 -0
  38. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/installation-parity.md +0 -0
  39. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/live-run.md +0 -0
  40. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/making-a-table-incremental.md +0 -0
  41. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/narrating-the-run.md +0 -0
  42. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/not-hosted-yet.md +0 -0
  43. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/one-install.md +0 -0
  44. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/prediction-labels.md +0 -0
  45. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/preparing-table-search.md +0 -0
  46. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/promote.md +0 -0
  47. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/receipts.md +0 -0
  48. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/recipe-test.md +0 -0
  49. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/recording-a-stream-venue.md +0 -0
  50. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/recovering-an-import-failure.md +0 -0
  51. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/reference-pages.md +0 -0
  52. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/required-protocol.md +0 -0
  53. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/source-credentials.md +0 -0
  54. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/the-one-thing-to-say-about-the-skill-itself.md +0 -0
  55. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/transforms.md +0 -0
  56. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/user-communication-contract.md +0 -0
  57. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/references/writing-a-decision-record.md +0 -0
  58. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/skills/mr-data-build/scripts/write_research_notebook.py +0 -0
  59. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/__init__.py +0 -0
  60. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/agent_protocol.py +0 -0
  61. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/canonical.py +0 -0
  62. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/formats.py +0 -0
  63. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/hosted_crawler_protocol.py +0 -0
  64. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/key_seam.py +0 -0
  65. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/page_coverage.py +0 -0
  66. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/part_check_evidence.py +0 -0
  67. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/session_probes.py +0 -0
  68. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/skill_assets.py +0 -0
  69. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/table_manifest.py +0 -0
  70. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/__init__.py +0 -0
  71. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/acquire.py +0 -0
  72. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/acquire_cancel.py +0 -0
  73. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/activity.py +0 -0
  74. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/approvals.py +0 -0
  75. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/backoff.py +0 -0
  76. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/categories.py +0 -0
  77. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/commands.py +0 -0
  78. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/dataset-categories-v1.json +0 -0
  79. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/delivery.py +0 -0
  80. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/description_shape.py +0 -0
  81. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/download.py +0 -0
  82. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/enrichment.py +0 -0
  83. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/fast_features.py +0 -0
  84. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/matching_benchmark.py +0 -0
  85. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/narrative.py +0 -0
  86. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/parity.py +0 -0
  87. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/probe.py +0 -0
  88. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/progress_rate.py +0 -0
  89. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/progress_vocabulary.py +0 -0
  90. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/propose.py +0 -0
  91. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/recipe_brief.py +0 -0
  92. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/recipe_explain.py +0 -0
  93. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/recipe_forecast.py +0 -0
  94. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/recipe_lint.py +0 -0
  95. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/recipe_test.py +0 -0
  96. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/refresh_rehearsal.py +0 -0
  97. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/release_check.py +0 -0
  98. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/research.py +0 -0
  99. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/router.py +0 -0
  100. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/run_diagnose.py +0 -0
  101. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/run_prefix.py +0 -0
  102. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/runs.py +0 -0
  103. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/stream.py +0 -0
  104. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/stream_venue.py +0 -0
  105. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/studio_capabilities.py +0 -0
  106. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/table_view.py +0 -0
  107. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/transport.py +0 -0
  108. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/user_agent.py +0 -0
  109. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_artifacts.py +0 -0
  110. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_catalog.py +0 -0
  111. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_connected.py +0 -0
  112. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_connections.py +0 -0
  113. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_dataset_covers.py +0 -0
  114. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_handoff.py +0 -0
  115. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_matching.py +0 -0
  116. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_narrative.py +0 -0
  117. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_public.py +0 -0
  118. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_runs.py +0 -0
  119. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_search_contract.py +0 -0
  120. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_secrets.py +0 -0
  121. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_stream.py +0 -0
  122. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/v4_tables.py +0 -0
  123. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/thin/vocabulary.py +0 -0
  124. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/__init__.py +0 -0
  125. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/attendance.py +0 -0
  126. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/clarification.py +0 -0
  127. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/cloud_auth.py +0 -0
  128. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/commands/__init__.py +0 -0
  129. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/commands/auth.py +0 -0
  130. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/commands/clarify.py +0 -0
  131. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/commands/login.py +0 -0
  132. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/commands/whoami.py +0 -0
  133. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/credential_native.py +0 -0
  134. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/credential_store.py +0 -0
  135. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/credentials.py +0 -0
  136. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/login.py +0 -0
  137. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/path_kind.py +0 -0
  138. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/src/mostlyright/data_harness/ux/plain_file.py +0 -0
  139. {mostlyright_data-0.31.13 → mostlyright_data-0.31.14}/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.31.13
3
+ Version: 0.31.14
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/
@@ -17,7 +17,7 @@ run in full.
17
17
  | `mr-data clarify` | Check whether clarification is allowed. Use `--attended` for an active user chat and `--unattended` for a scheduled invocation; an explicit operator veto such as `MOSTLYRIGHT_ATTENDED=0` still wins. A non-TTY subprocess does not prove the user is absent. The command does not ask the person in chat or wait for a reply; the agent must do that. It exits non-zero when nobody can answer or the recipe is already registered. |
18
18
  | `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. **It rides the frozen `/v3/sessions` routes, which a deployment may have switched off, so do not plan a source inspection around it.** Both positionals are identifiers. `QUESTION_ID` is a question identifier, which no v4 registration receipt returns. The v4 way to look at a source before registering it is `mr-data source inspect`, in the next row. The other way to read one is a bounded run of a scratch table, never of the real table, as [the probe rule](3-probe-read-a-source-before-committing-to-it.md#the-probe-rule-test-on-a-scratch-table-never-on-the-real-table) says. |
19
19
  | `mr-data source` | `source inspect` takes a bounded look at one source, and it is the v4 path where `probe` is not. [Stage 3](3-probe-read-a-source-before-committing-to-it.md) says when to use it. It starts one of three ways, and each prints the `session_id`, `probe_id` and `source_id`, plus the `dataset_id` and `question_id` when it has them. For a new public HTTPS CSV that needs no key, give `--dataset-id`, `--question`, `--source-name`, `--source-url`, `--claimed-basis` and `--claim-evidence-digest`: it prepares the source and opens a session. For a source already prepared, give `--dataset-id`, `--question-id` and `--source-id`, which opens a session. In a session you already hold, give `--session-id` with `--source-id`. `--context-source-id` adds a source to a session it opens. `--session-id` with `--probe-id` reads the answer back. A look is bounded to 65,536 bytes, 1,000 rows and 300 seconds. `queued` and `truncated` are not complete answers, and `capability_unavailable` means this deployment cannot inspect, so read the source by a bounded run of a scratch table instead, as [the probe rule](3-probe-read-a-source-before-committing-to-it.md#the-probe-rule-test-on-a-scratch-table-never-on-the-real-table) says. What it finds is exploratory evidence, not a run and not a receipt. |
20
- | `mr-data reader` | `reader normalize --family-id ID --family-version VERSION --options-file FILE [--workspace-id ID]` asks Studio's Reader service to apply one certified family's own defaults and option rules to a JSON object of options, at most 64 KiB, and prints the normalized `decode_options` and `decode_options_json`. The second is the text a recipe carries as `reader.decode_options`, pinned as [Readers](readers.md) describes. It changes nothing, and this client does not validate or invent a family's options: Studio decides. Run it for every Reader pin before you register, so a wrong option is refused here and not inside a run. |
20
+ | `mr-data reader` | `reader normalize --family-id ID --family-version VERSION --options-file FILE [--workspace-id ID]` asks Studio's Reader service to apply one certified family's own defaults and option rules to a JSON object of options, at most 64 KiB, and prints the normalized `decode_options`, `decode_options_json` and `options_digest`. The second is the text a recipe carries as `reader.decode_options`, and the third is the `reader.options_digest` registration accepts for exactly those options, pinned as [Readers](readers.md) describes. It changes nothing, and this client does not validate or invent a family's options: Studio decides. Run it for every Reader pin before you register, so a wrong option is refused here and not inside a run. |
21
21
  | `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. |
22
22
  | `mr-data public` | Read Mostly Right's public dataset catalogue. `public search "QUERY"` ranks published datasets (`--topic`, `--limit` and `--cursor` narrow and page it), `show SLUG` reads one overview, `tables SLUG` lists its table identifiers, `schema TABLE_ID` reads one table's schema, `sample TABLE_ID` prints its published preview rows, `lookup "QUERY"` is the connector search shape and `fetch SLUG` returns the dataset's Markdown document. Those seven need no login. `query TABLE_ID`, with `--body` or `--body-file`, and `download-url TABLE_ID` do need `mr-data login`: they ask Cloud, with the device key, to run one query over a public table and to say how it can be downloaded. Preview rows are not a query, and no table bytes pass through the command. |
23
23
  | `mr-data connected` | `connected list` shows the public datasets connected to this workspace and `connected add SLUG` connects one by its slug. It needs `mr-data login`: both ask Cloud, with the device key, over the routes MCP's `list_connected_datasets` and `connect_dataset` use. |
@@ -225,8 +225,9 @@ for the days that can still change, through the two request `parameters` it rend
225
225
  unit reads its own page count, so size `limits.max_requests` from a measured unit.
226
226
  [Paging a keyed API in parallel](sources.md#paging-a-keyed-api-in-parallel) has the rules.
227
227
 
228
- `options_digest` is the SHA-256 of the canonical JSON of `decode_options`. Registration names it
229
- when the two disagree.
228
+ `options_digest` is the SHA-256 of the canonical JSON of `decode_options`, and
229
+ `mr-data reader normalize` prints it for the options it returns. Registration names the digest it
230
+ expected when the two disagree.
230
231
 
231
232
  ```json
232
233
  {
@@ -57,9 +57,17 @@ mr-data reader normalize --family-id json.tabular --family-version 1.1.0 \
57
57
 
58
58
  `options.json` holds the keys the source needs, at most 64 KiB. Studio applies the family's own
59
59
  defaults and option rules. The answer's `decode_options_json` is the text to write as
60
- `reader.decode_options`. A refusal arrives here, before any run is spent. Run it for every
60
+ `reader.decode_options`, and its `options_digest` is the `reader.options_digest` to pin beside it.
61
+ That digest binds the default-filled options the answer returns, not the subset in `options.json`,
62
+ so copy both together. A refusal arrives here, before any run is spent. Run it for every
61
63
  Reader pin before you register.
62
64
 
65
+ `normalize` checks the options against the family, not against the place the pin goes. A
66
+ Generic API `generic_api.response.reader` admits fewer coordinates: `json.tabular` `1.0.0`,
67
+ `1.1.0` or `1.2.0`, `delimited_text` `1.0.0` or `1.1.0`, and `parquet.tabular` `1.0.0`.
68
+ `json.tabular` `1.3.0` and `1.4.0` normalize but are refused there. The highest-version advice
69
+ below applies inside that set.
70
+
63
71
  A column that may be absent from a record must state `"required": false`. Publishers routinely omit
64
72
  a key rather than sending null, and a required column that is missing refuses the record.
65
73
 
@@ -87,6 +95,7 @@ decoder. Zero matches and multiple matches both refuse.
87
95
  | `archive.zip@1.0.0` | `member`; `member_format` (`csv` or `tsv`); optional `header: true`. |
88
96
  | `archive.zip@1.1.0` | Same, plus `member_format: semicolon` for publishers such as DWD. |
89
97
  | `archive.zip@1.2.0` | Same as `archive.zip@1.1.0`; also accepts a response served as `application/octet-stream`. |
98
+ | `archive.zip@4.0.0` | Exact safe `member`, `member_format: lines` and `encoding: utf-8 or cp1252`; no header. Streams the member's physical lines as line_number, raw_line, as `archive.tar@2.0.0` does for one member. |
90
99
  | `archive.gzip@1.0.0` | `member_format`; optional `header: true`. `member` is invalid. |
91
100
  | `archive.gzip@1.1.0` | Same; also accepts a response served as `application/octet-stream`. |
92
101
  | `archive.tar@1.0.0` | Same as `archive.zip@1.0.0`. |
@@ -181,7 +190,8 @@ Use `public.https.api@1.0.0` only when `keyless_window` is literal `true`. Other
181
190
  The new adapter uses the existing `generic_api.response` Reader contract. For Binance klines,
182
191
  state `format: json`, `records_pointer: ""`, and `reader.family_id: json.tabular` with
183
192
  `family_version: 1.0.0` (or the already admitted `1.1.0`). Pin `options_digest` to the SHA-256
184
- of the canonical decode options, rather than inserting a placeholder digest. These decode
193
+ of the canonical decode options, the `options_digest` `mr-data reader normalize` prints, rather
194
+ than inserting a placeholder digest. These decode
185
195
  options project a root JSON array of arrays, with RFC 6901 numeric indexes into each row:
186
196
 
187
197
  ```json
@@ -335,6 +335,10 @@ to separate them. No per-row collection may derive its rows from a `closed` or `
335
335
  `pagination` is a `page` or `offset` walk with a required `parallel` member, in one of two forms.
336
336
  Prefer the counted form.
337
337
 
338
+ `@2.1.0` refuses `{"kind": "none", "max_pages": 1}`, and `cursor` and `link` walks too. For one
339
+ request, use `@2.0.0`. Use `authenticated.https.connector@3.0.0` instead when the request
340
+ declares a `per_call` cost, which `@2.0.0` does not admit.
341
+
338
342
  | Form | Members | Use it when |
339
343
  | --- | --- | --- |
340
344
  | counted | `total_pages_pointer`, `max_page_bytes`, `min_interval_milliseconds`, optional `concurrency` | the first page states how many pages exist. A `page` walk over a `json` response only |
@@ -170,12 +170,12 @@ FILE_DECODER_COORDINATES: frozenset[tuple[str, str]] = frozenset(
170
170
  for family, versions in {
171
171
  "archive.gzip": ("1.0.0", "1.1.0", "2.0.0", "2.1.0"),
172
172
  "archive.tar": ("1.0.0", "1.1.0", "3.0.0", "4.0.0"),
173
- "archive.zip": ("1.0.0", "1.1.0", "1.2.0", "1.3.0", "1.4.0", "1.5.0", "3.0.0"),
173
+ "archive.zip": ("1.0.0", "1.1.0", "1.2.0", "1.3.0", "1.4.0", "1.5.0", "3.0.0", "4.0.0"),
174
174
  "delimited_text": ("1.0.0", "1.1.0", "2.0.0"),
175
175
  "geoparquet.vector": ("1.0.0",),
176
176
  "json.tabular": ("1.1.0", "1.2.0"),
177
177
  "meteorology.bufr": ("1.0.0",),
178
- "meteorology.grib": ("1.0.0",),
178
+ "meteorology.grib": ("1.0.0", "2.0.0"),
179
179
  "microdata.sas_xpt": ("1.0.0",),
180
180
  "microdata.spss_sav": ("1.0.0",),
181
181
  "microdata.stata_dta": ("1.0.0",),
@@ -587,6 +587,19 @@ def sessions_for(session: StudioSession | StudioSessionProvider) -> StudioSessio
587
587
  #: a sentence with spaces in it.
588
588
  CLOUD_REFUSAL_CODE = re.compile(r"[A-Z][A-Z0-9_]{0,63}")
589
589
 
590
+ #: Cloud's refusal of a web chat sandbox key on a route that would change something
591
+ #: (Cloud ``lib/sandbox-key-refusal.ts``). The key is valid and reads; every change it
592
+ #: would make goes through the chat's own approval-gated tools or a person on the dashboard.
593
+ #: Folded onto ``THIN_AUTHORIZATION_DENIED`` it told an agent in the web chat to run
594
+ #: ``mr-data login``, which cannot help, so it keeps its own code -- and Cloud's sentence,
595
+ #: which names the chat tool that does the job.
596
+ SANDBOX_KEY_READ_ONLY_CODE = "SANDBOX_KEY_READ_ONLY"
597
+ THIN_SANDBOX_KEY_READ_ONLY = f"THIN_CLOUD_{SANDBOX_KEY_READ_ONLY_CODE}"
598
+ SANDBOX_KEY_READ_ONLY_SENTENCE = (
599
+ "This is a web chat key: it reads, and changes go through the chat's own tools or a "
600
+ "person on the dashboard; signing in again will not change that"
601
+ )
602
+
590
603
  #: The family of Studio's refusals of a call that would skip Cloud's usage meter
591
604
  #: (``DATA_EGRESS_THROUGH_CLOUD``). From a Cloud route it means Studio refused CLOUD's call, which
592
605
  #: is Mostly Right's misconfiguration and never the caller's key.
@@ -844,6 +857,18 @@ def cloud_refusal(
844
857
  "this computer's or the device key's; nothing here needs to change. Run the command "
845
858
  "again later",
846
859
  )
860
+ elif status == 403 and named == SANDBOX_KEY_READ_ONLY_CODE:
861
+ # NOT A MISSING OR EXPIRED KEY, so not the shared authorization code: the chat's
862
+ # key is valid and only this change is not its to make. Cloud's sentence names the
863
+ # tool that makes it, so it is quoted (bounded, printable, attributed) even though a
864
+ # code was named, which no other named refusal here does.
865
+ stated_alternative = _cloud_sentence(parsed.get("error"))
866
+ refusal = ThinLaneError(
867
+ THIN_SANDBOX_KEY_READ_ONLY,
868
+ f"{SANDBOX_KEY_READ_ONLY_SENTENCE}. Cloud said: {stated_alternative}"
869
+ if stated_alternative
870
+ else SANDBOX_KEY_READ_ONLY_SENTENCE,
871
+ )
847
872
  elif status in {401, 403}:
848
873
  if named:
849
874
  suffix = f" (Cloud said {named}, HTTP {status})"
@@ -31,7 +31,11 @@ from typing import Any
31
31
 
32
32
  from mostlyright.data_harness.thin import THIN_SCHEMA_PREFIX
33
33
  from mostlyright.data_harness.thin.backoff import jittered
34
- from mostlyright.data_harness.thin.session import CloudClient, require_device_key
34
+ from mostlyright.data_harness.thin.session import (
35
+ THIN_SANDBOX_KEY_READ_ONLY,
36
+ CloudClient,
37
+ require_device_key,
38
+ )
35
39
  from mostlyright.data_harness.thin.transport import ThinLaneError, UrlLibTransport
36
40
  from mostlyright.data_harness.ux.credentials import resolve_cloud_credentials
37
41
 
@@ -61,6 +65,14 @@ SETTLED = frozenset({"answered", "budget_exhausted", "failed"})
61
65
  SETTLED_RECEIPTS = frozenset(f"support_{state}" for state in SETTLED)
62
66
  STATUS_VERB = "status"
63
67
 
68
+ #: What a web chat key is told when Cloud refuses it this route. Said here as well as by
69
+ #: Cloud, so an agent in the chat learns the tool even if Cloud's sentence did not survive
70
+ #: the bound on quoted text.
71
+ CHAT_SUPPORT_TOOLS = (
72
+ "In the web chat, ask with the chat's ask_support tool, then read the answer with "
73
+ "get_support_answer"
74
+ )
75
+
64
76
 
65
77
  def declare_arguments(parser: argparse.ArgumentParser) -> None:
66
78
  """Add `ask`'s arguments to the shared hosted parser."""
@@ -106,17 +118,29 @@ def declare_arguments(parser: argparse.ArgumentParser) -> None:
106
118
  )
107
119
 
108
120
 
121
+ def _name_chat_tools(failure: ThinLaneError) -> ThinLaneError:
122
+ """A web chat key refused here is pointed at the chat's own support tools."""
123
+
124
+ if failure.code == THIN_SANDBOX_KEY_READ_ONLY and "ask_support" not in failure.detail:
125
+ failure.detail = f"{failure.detail}. {CHAT_SUPPORT_TOOLS}"
126
+ failure.args = (f"{failure.code}: {failure.detail}",)
127
+ return failure
128
+
129
+
109
130
  class SupportClient(CloudClient):
110
131
  """The two support routes over the shared device-key Cloud client."""
111
132
 
112
133
  def submit(self, body: Mapping[str, Any]) -> dict[str, Any]:
113
- return self.call(
114
- "POST",
115
- CLOUD_SUPPORT_QUESTIONS_PATH,
116
- act="ask the support agent a question",
117
- body=body,
118
- expected=(202,),
119
- )
134
+ try:
135
+ return self.call(
136
+ "POST",
137
+ CLOUD_SUPPORT_QUESTIONS_PATH,
138
+ act="ask the support agent a question",
139
+ body=body,
140
+ expected=(202,),
141
+ )
142
+ except ThinLaneError as failure:
143
+ raise _name_chat_tools(failure) from None
120
144
 
121
145
  def read(self, question_id: str, *, run: bool) -> dict[str, Any]:
122
146
  suffix = "" if run else "?wait=0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": "mostlyright-thin-table-search-contract.v1",
3
- "source_commit": "5109da1f92eaaf4fc41ed35a03e5d010268d7c7b",
3
+ "source_commit": "b296c21484f40cce334e1b7c0f6c7c03b44a41e9",
4
4
  "recipe_search_definition": "recipe_search",
5
5
  "$defs": {
6
6
  "common.safe_integer": {
@@ -234,6 +234,12 @@
234
234
  "asc",
235
235
  "desc"
236
236
  ]
237
+ },
238
+ "quality": {
239
+ "description": "Optional owner quality note (4.55.0). A string of 1 to 200 characters. Shown on the order question only when this phrase occurs in the query. It does not change the sort. A boolean is invalid.",
240
+ "type": "string",
241
+ "minLength": 1,
242
+ "maxLength": 200
237
243
  }
238
244
  },
239
245
  "required": [
@@ -242,7 +248,7 @@
242
248
  "direction"
243
249
  ]
244
250
  },
245
- "description": "Order phrases (4.45.0: at most 256), one package order key each, under the aliases' shared byte budget."
251
+ "description": "Order phrases (4.45.0: at most 256), one package order key each, under the aliases' shared byte budget. Optional quality (4.55.0) is the owner's note for that phrase."
246
252
  },
247
253
  "default_order": {
248
254
  "description": "The order of a search with nothing to rank by (4.44.0): a browse or an entity plan with no order of its own walks the rows in this order instead of the package's row order (popularity). Mojirama's `known` (vote count descending) for a film table; the column must be sortable (declared sortable, a sort alias's, or the popularity or votes role).",
@@ -149,8 +149,8 @@ from mostlyright.data_harness.thin.v4_matching import CONTRACT_PATHS as MATCHING
149
149
  #: ``JOB_INVALID`` outright, which in production was every refresh a collection epoch was offered
150
150
  #: for rather than only the collection runs. This package must not reach a Studio older than the
151
151
  #: commit it pins; ``docs/V4-WORKER-PROTOCOL.md`` states it beside the layout's own ordering rule.
152
- PINNED_V4_OPENAPI_SOURCE_SHA256 = "e7bd4e994b32d62ae00b4d7fc2f8936e4964cd4160b7aa30ae010528ff57b1a8"
153
- PINNED_V4_CONTRACT_VERSION = "4.54.0"
152
+ PINNED_V4_OPENAPI_SOURCE_SHA256 = "b378d4f70d580aed094b00edeec5acc39cae5c79f19dc4e869fe40304fccf654"
153
+ PINNED_V4_CONTRACT_VERSION = "4.57.0"
154
154
 
155
155
  #: The ``schema_version`` every command body that predates 4.14.0 stamps: ``dataset activity``,
156
156
  #: ``table rename``, ``table archive`` and ``dataset archive``. It is the number the 0.25 line
@@ -136,6 +136,7 @@ from mostlyright.data_harness.thin.session import (
136
136
  CLOUD_REFUSAL_CODE,
137
137
  MAX_JSON_RESPONSE_BYTES,
138
138
  MAX_STATED_MESSAGE_CHARS,
139
+ THIN_SANDBOX_KEY_READ_ONLY,
139
140
  CloudClient,
140
141
  StudioSession,
141
142
  StudioSessionProvider,
@@ -1615,18 +1616,30 @@ def _sync_written_identity(dataset_id: str, cloud_url: str) -> dict[str, Any]:
1615
1616
  )
1616
1617
  return {"public_projection_synced": True}
1617
1618
  except (ThinLaneError, LoginError, OSError) as error:
1618
- return {
1619
+ stated = isinstance(error, ThinLaneError) and getattr(error, "http_status", None) != 404
1620
+ result: dict[str, Any] = {
1619
1621
  "public_projection_synced": False,
1620
- "public_projection_error": (
1621
- error.code
1622
- if isinstance(error, ThinLaneError) and getattr(error, "http_status", None) != 404
1623
- else "THIN_IDENTITY_SYNC_UNAVAILABLE"
1624
- ),
1622
+ "public_projection_error": (error.code if stated else "THIN_IDENTITY_SYNC_UNAVAILABLE"),
1625
1623
  "public_projection_note": (
1626
1624
  "The dataset was saved, but its public page could not be updated. "
1627
1625
  f"Retry only the sync with: mr-data dataset sync {dataset_id}"
1628
1626
  ),
1629
1627
  }
1628
+ # ⚠ THE CODE ALONE SENT PEOPLE TO THE WRONG FIX. Every refusal on this path left
1629
+ # as a bare code, so a key Cloud would never let update the page and an expired one
1630
+ # read the same. The refusal's own sentence -- this lane's words, with Cloud's
1631
+ # quoted inside them already bounded and printable by ``cloud_refusal`` -- rides
1632
+ # along. Nothing branches on it; the code beside it is what a script reads.
1633
+ if stated:
1634
+ result["public_projection_message"] = error.detail
1635
+ if stated and error.code == THIN_SANDBOX_KEY_READ_ONLY:
1636
+ # Retrying with this key is refused the same way: the chat does it.
1637
+ result["public_projection_note"] = (
1638
+ "The dataset was saved, but this web chat key cannot update its public "
1639
+ "page. Use the chat's update_dataset tool with sync_only: true, or a "
1640
+ "person syncs it from the dashboard."
1641
+ )
1642
+ return result
1630
1643
 
1631
1644
 
1632
1645
  def _held(record: Mapping[str, Any], member: str) -> Any:
@@ -43,6 +43,7 @@ import os
43
43
  import re
44
44
  import time
45
45
  from collections.abc import Callable, Mapping
46
+ from datetime import datetime
46
47
  from http.client import HTTPException
47
48
  from pathlib import Path
48
49
  from typing import Any
@@ -124,6 +125,20 @@ MAX_QUERY_WAIT_SECONDS = 120.0
124
125
  QUERY_POLL_INITIAL_SECONDS = 0.2
125
126
  QUERY_POLL_MAX_SECONDS = 2.0
126
127
 
128
+ #: How long past the query's OWN deadline this command keeps reading before it stops.
129
+ #:
130
+ #: ⚠ THE SERVER'S DEADLINE BOUNDS THE WAIT TOO, NOT ONLY THIS CLIENT'S PATIENCE. Studio's deadline
131
+ #: (``deadline_at - submitted_at``, thirty seconds by default) is shorter than the two ceilings
132
+ #: above, so a query no worker took used to be read back for over a minute after Studio had stopped
133
+ #: caring about it, and then reported as merely "still running". The window is measured on this
134
+ #: computer's monotonic clock from the first read, using the server's own two stamps for its
135
+ #: length, so a skewed wall clock here can neither cut it short nor stretch it. The grace covers
136
+ #: a settle that lands on the deadline itself.
137
+ QUERY_DEADLINE_GRACE_SECONDS = 5.0
138
+
139
+ #: The refusal a query gets when Studio still reports it unsettled after its own deadline.
140
+ QUERY_DEADLINE_PASSED_CODE = "THIN_QUERY_DEADLINE_PASSED"
141
+
127
142
  #: The submit body's bound, in canonical-JSON bytes, and the same number Studio enforces. Checked
128
143
  #: here so an oversized submission is a sentence at the terminal rather than a 422 after a token
129
144
  #: has been minted -- the reason every early refusal in this repository exists.
@@ -1071,7 +1086,7 @@ def _settled(
1071
1086
  query_id = _record_identifier(record, run_id)
1072
1087
  checkpoint_content_digest = _checkpoint_binding(record, checkpoint_id)
1073
1088
  try:
1074
- record, read = _poll(
1089
+ record, read, overdue = _poll(
1075
1090
  selected,
1076
1091
  run_id,
1077
1092
  query_id,
@@ -1082,6 +1097,8 @@ def _settled(
1082
1097
  )
1083
1098
  except ThinLaneError as refusal:
1084
1099
  raise _while_reading_back(refusal, query_id) from None
1100
+ if overdue and record.get("state") not in TERMINAL_QUERY_STATES:
1101
+ raise _deadline_passed(record, query_id, read)
1085
1102
  head: dict[str, Any] = {
1086
1103
  "schema_version": QUERY_SCHEMA,
1087
1104
  "lane": "hosted",
@@ -1134,6 +1151,58 @@ def _settled(
1134
1151
  return payload
1135
1152
 
1136
1153
 
1154
+ def _server_window_seconds(record: Mapping[str, Any]) -> float | None:
1155
+ """The deadline Studio gave this query, in seconds after its submission, or ``None``.
1156
+
1157
+ Read from the record's own two stamps rather than compared with this computer's wall clock,
1158
+ which can be minutes off from Studio's.
1159
+ """
1160
+
1161
+ submitted = record.get("submitted_at")
1162
+ deadline = record.get("deadline_at")
1163
+ if not isinstance(submitted, str) or not isinstance(deadline, str):
1164
+ return None
1165
+ try:
1166
+ ends = datetime.fromisoformat(deadline.replace("Z", "+00:00"))
1167
+ began = datetime.fromisoformat(submitted.replace("Z", "+00:00"))
1168
+ except ValueError:
1169
+ return None
1170
+ # A stamp without an offset is not an instant, and subtracting a naive one from an aware one
1171
+ # raises TypeError: either way the record states no window, and the old ceilings decide.
1172
+ if ends.tzinfo is None or began.tzinfo is None:
1173
+ return None
1174
+ try:
1175
+ window = (ends - began).total_seconds()
1176
+ except (TypeError, OverflowError):
1177
+ return None
1178
+ return window if window >= 0 else None
1179
+
1180
+
1181
+ def _deadline_passed(record: Mapping[str, Any], query_id: str, read: int) -> ThinLaneError:
1182
+ """A query Studio still calls unsettled after its own deadline, as the terminal refusal it is.
1183
+
1184
+ Not "still running": past ``deadline_at`` nothing will answer this query, and an agent told to
1185
+ wait would wait for nothing. What it can do is said: run the command again, which queues the
1186
+ same question afresh because a query past its deadline is re-run rather than replayed, and, if
1187
+ that also stalls, read the table without a worker.
1188
+ """
1189
+
1190
+ state = record.get("state")
1191
+ refusal = ThinLaneError(
1192
+ QUERY_DEADLINE_PASSED_CODE,
1193
+ f"query {query_id} was still {state} {int(QUERY_DEADLINE_GRACE_SECONDS)} seconds after "
1194
+ f"its deadline {record.get('deadline_at')} ({read} reads), so no worker answered it in "
1195
+ "time and nothing was returned. Running this same command again queues the question "
1196
+ "afresh under the same identifier. If it stalls again, no session worker is taking this "
1197
+ "workspace's queries: mr-data download "
1198
+ f"{record.get('run_id') or 'RUN'} brings the table here to query locally",
1199
+ )
1200
+ refusal.query_id = query_id # type: ignore[attr-defined]
1201
+ refusal.deadline_at = record.get("deadline_at") # type: ignore[attr-defined]
1202
+ refusal.state = state # type: ignore[attr-defined]
1203
+ return refusal
1204
+
1205
+
1137
1206
  #: The file the queries this computer has asked are remembered in, beside the release check's.
1138
1207
  QUERY_MEMO_NAME = "queries.json"
1139
1208
 
@@ -1450,15 +1519,20 @@ def _poll(
1450
1519
  checkpoint_content_digest: str | None,
1451
1520
  sleep: Callable[[float], None],
1452
1521
  clock: Callable[[], float],
1453
- ) -> tuple[dict[str, Any], int]:
1454
- """Read the record back until it settles, or until one of the two ceilings arrives.
1522
+ ) -> tuple[dict[str, Any], int, bool]:
1523
+ """Read the record back until it settles, until one of the two ceilings arrives, or until
1524
+ the query's own deadline (plus :data:`QUERY_DEADLINE_GRACE_SECONDS`) has passed.
1455
1525
 
1456
1526
  The first read is taken immediately rather than after a sleep: what is in hand on entry is the
1457
1527
  SUBMIT's own answer, written before any worker could have claimed it, so a query that settled
1458
1528
  while the submit was returning would otherwise be reported a whole interval after the fact.
1529
+
1530
+ The third member says the server deadline is what ended the wait.
1459
1531
  """
1460
1532
 
1461
- deadline = clock() + MAX_QUERY_WAIT_SECONDS
1533
+ started = clock()
1534
+ deadline = started + MAX_QUERY_WAIT_SECONDS
1535
+ overdue_at: float | None = None
1462
1536
  interval = QUERY_POLL_INITIAL_SECONDS
1463
1537
  record: dict[str, Any] = {}
1464
1538
  read = 0
@@ -1466,12 +1540,21 @@ def _poll(
1466
1540
  if attempt:
1467
1541
  if clock() >= deadline:
1468
1542
  break
1469
- sleep(interval)
1543
+ if overdue_at is not None and clock() >= overdue_at:
1544
+ return record, read, True
1545
+ pause = interval
1546
+ if overdue_at is not None:
1547
+ pause = max(0.0, min(pause, overdue_at - clock()))
1548
+ sleep(pause)
1470
1549
  interval = min(interval * 2, QUERY_POLL_MAX_SECONDS)
1471
1550
  if clock() >= deadline:
1472
1551
  break
1473
1552
  record = client.run_query(run_id, query_id)
1474
1553
  read += 1
1554
+ if overdue_at is None:
1555
+ window = _server_window_seconds(record)
1556
+ if window is not None:
1557
+ overdue_at = started + window + QUERY_DEADLINE_GRACE_SECONDS
1475
1558
  if record.get("run_id") not in (None, run_id):
1476
1559
  raise ThinLaneError(
1477
1560
  "THIN_RESPONSE_INVALID", "Studio answered with a query belonging to another run"
@@ -1479,7 +1562,9 @@ def _poll(
1479
1562
  _checkpoint_binding(record, checkpoint_id, checkpoint_content_digest)
1480
1563
  if record.get("state") in TERMINAL_QUERY_STATES:
1481
1564
  break
1482
- return record, read
1565
+ if overdue_at is not None and clock() >= overdue_at:
1566
+ return record, read, True
1567
+ return record, read, False
1483
1568
 
1484
1569
 
1485
1570
  def query_exit_code(payload: Mapping[str, Any]) -> int:
@@ -1499,6 +1584,8 @@ __all__ = [
1499
1584
  "MAX_QUERY_WAIT_SECONDS",
1500
1585
  "MAX_STATEMENT_BYTES",
1501
1586
  "PYTZ_NOT_IMPORTED",
1587
+ "QUERY_DEADLINE_GRACE_SECONDS",
1588
+ "QUERY_DEADLINE_PASSED_CODE",
1502
1589
  "QUERY_MEMO_ENTRIES",
1503
1590
  "QUERY_MEMO_NAME",
1504
1591
  "QUERY_POLL_INITIAL_SECONDS",
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import hashlib
6
7
  import re
7
8
  from collections.abc import Mapping
8
9
  from typing import Any
@@ -141,6 +142,7 @@ class StudioV4ReaderClient(StudioV4RunClient):
141
142
  "family_version",
142
143
  "decode_options",
143
144
  "decode_options_json",
145
+ "options_digest",
144
146
  }
145
147
  ):
146
148
  raise ThinLaneError(
@@ -160,6 +162,30 @@ class StudioV4ReaderClient(StudioV4RunClient):
160
162
  raise ThinLaneError(
161
163
  "THIN_RESPONSE_INVALID", "Studio returned inconsistent reader JSON"
162
164
  ) from error
165
+ # The recipe's `reader.options_digest`: SHA-256 of the canonical JSON of the returned
166
+ # (default-filled) options, the exact value registration recomputes for a recipe that
167
+ # freezes this `decode_options`. Hashed from THIS client's canonical bytes for the object,
168
+ # never from Studio's string, and those bytes must equal Studio's `decode_options_json`:
169
+ # a string that parses equal but is spelled differently would hash to a digest
170
+ # registration refuses.
171
+ local = canonical_json_bytes(response["decode_options"])
172
+ if local != response["decode_options_json"].encode("utf-8"):
173
+ raise ThinLaneError(
174
+ "THIN_RESPONSE_INVALID",
175
+ "Studio's decode_options_json is not the canonical JSON of its decode_options, "
176
+ "so no options_digest can be stated for it",
177
+ )
178
+ digest = hashlib.sha256(local).hexdigest()
179
+ # ⚠ FORWARD-LOOKING ONLY. Studio 4.57.0 states `options_digest` when the command sets
180
+ # `include_options_digest: true`, but every older Studio's normalize command is
181
+ # `additionalProperties: false` and would 422 the flag, and this client negotiates no
182
+ # contract version before the call. So the flag is not sent and this cross-check runs only
183
+ # against a Studio that states the digest unasked.
184
+ if "options_digest" in response and response["options_digest"] != digest:
185
+ raise ThinLaneError(
186
+ "THIN_RESPONSE_INVALID", "Studio returned a reader options_digest for other options"
187
+ )
188
+ response["options_digest"] = digest
163
189
  return response
164
190
 
165
191
  def open_research_session(self, body: Mapping[str, Any]) -> dict[str, Any]:
@@ -1529,7 +1529,7 @@ def _workbench_read(
1529
1529
  # rows: only a failure is settled from the handle alone, and every other state is polled.
1530
1530
  if handle.get("state") != "failed":
1531
1531
  try:
1532
- record, polls = v4_query._poll(
1532
+ record, polls, overdue = v4_query._poll(
1533
1533
  selected,
1534
1534
  run_id,
1535
1535
  query_id,
@@ -1547,6 +1547,8 @@ def _workbench_read(
1547
1547
  f"{_poll_path(run_id, query_id)}. Running this same command again converges "
1548
1548
  "on that same query rather than queueing a second",
1549
1549
  ) from None
1550
+ if overdue and record.get("state") not in v4_query.TERMINAL_QUERY_STATES:
1551
+ raise v4_query._deadline_passed(record, query_id, polls)
1550
1552
  if record.get("query_id") not in (None, query_id):
1551
1553
  raise ThinLaneError(
1552
1554
  "THIN_RESPONSE_INVALID", "Studio answered the poll with another query"
@@ -11,7 +11,7 @@ neither affordable nor honest:
11
11
  * **Families.** Every code prefix is assigned to a family -- 224 of them, plus 21 compound
12
12
  prefixes, across 39 families. A family is one paragraph: what this class of failure is about,
13
13
  where to look, and what to do. Every prefix has one, so no code can fail to say something useful.
14
- * **Curated codes.** The 120 failures a person actually meets get their own exact sentences, and
14
+ * **Curated codes.** The 121 failures a person actually meets get their own exact sentences, and
15
15
  where one command is the whole answer, the command itself.
16
16
 
17
17
  Those four counts are not a note somebody remembered to update:
@@ -2308,6 +2308,17 @@ CODES: dict[str, Remediation] = {
2308
2308
  "account counts toward that account's monthly allowance instead.",
2309
2309
  ),
2310
2310
  ),
2311
+ # A web chat key refused a change (Cloud lib/sandbox-key-refusal.ts). The hosted-lane
2312
+ # paragraph would send it to mr-data login, which cannot help.
2313
+ "THIN_CLOUD_SANDBOX_KEY_READ_ONLY": Remediation(
2314
+ lines=(
2315
+ "This command ran with a web chat key. It reads, and the key is valid; changes "
2316
+ "go through the chat's own tools, which the person approves there, or a person "
2317
+ "makes them on the dashboard.",
2318
+ "The message names the chat tool that does this. Signing in again or replacing the "
2319
+ "key will not change the answer.",
2320
+ ),
2321
+ ),
2311
2322
  "THIN_CLOUD_UNREACHABLE": Remediation(
2312
2323
  lines=(
2313
2324
  "Mostly Right Cloud did not answer: the connection was refused, cut, or timed out.",