mostlyright-data 0.31.10__tar.gz → 0.31.12__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.10 → mostlyright_data-0.31.12}/PKG-INFO +1 -1
  2. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/preparing-table-search.md +75 -12
  3. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/recipe-test.md +4 -3
  4. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/activity.py +6 -5
  5. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe_test.py +46 -11
  6. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/runs.py +96 -7
  7. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/session.py +83 -29
  8. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/table_search_contract.json +44 -1
  9. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4.py +2 -2
  10. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_runs.py +10 -1
  11. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_search.py +31 -1
  12. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_search_contract.py +54 -3
  13. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/.gitignore +0 -0
  14. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/README.md +0 -0
  15. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/pyproject.toml +0 -0
  16. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/scripts/hatch_build.py +0 -0
  17. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/SKILL.md +0 -0
  18. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/agents/openai.yaml +0 -0
  19. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/1-open-the-page-and-the-link-to-it-in-the-first-message.md +0 -0
  20. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/2-brief-two-to-four-questions-each-with-a-recommended-answer.md +0 -0
  21. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/3-probe-read-a-source-before-committing-to-it.md +0 -0
  22. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/4-decide-say-what-you-chose-what-you-refused-and-ask-one-question.md +0 -0
  23. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/5-draft-one-recipe-document-one-call.md +0 -0
  24. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/6-build-one-run-sized-to-acquire-every-measured-source-whole.md +0 -0
  25. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/7-interrogate-ask-the-run-what-it-actually-delivered.md +0 -0
  26. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/8-fix-revise-the-document-and-register-it-again.md +0 -0
  27. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/9-present-only-what-survived-inspection-with-caveats.md +0 -0
  28. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/agent-protocol.md +0 -0
  29. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/autonomous-delivery.md +0 -0
  30. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/before-the-first-tool-call.md +0 -0
  31. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/boundaries.md +0 -0
  32. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/cloud-authentication-preflight.md +0 -0
  33. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/commands.md +0 -0
  34. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/cross-repository-protocol-reference.md +0 -0
  35. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/enriching-with-small-models.md +0 -0
  36. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/installation-parity.md +0 -0
  37. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/live-run.md +0 -0
  38. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/making-a-table-incremental.md +0 -0
  39. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/narrating-the-run.md +0 -0
  40. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/not-hosted-yet.md +0 -0
  41. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/one-install.md +0 -0
  42. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/patterns.md +0 -0
  43. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/prediction-labels.md +0 -0
  44. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/promote.md +0 -0
  45. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/readers.md +0 -0
  46. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/receipts.md +0 -0
  47. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/recording-a-stream-venue.md +0 -0
  48. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/recovering-an-import-failure.md +0 -0
  49. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/reference-pages.md +0 -0
  50. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/required-protocol.md +0 -0
  51. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/source-credentials.md +0 -0
  52. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/sources.md +0 -0
  53. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/the-one-thing-to-say-about-the-skill-itself.md +0 -0
  54. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/transforms.md +0 -0
  55. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/user-communication-contract.md +0 -0
  56. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/references/writing-a-decision-record.md +0 -0
  57. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/skills/mr-data-build/scripts/write_research_notebook.py +0 -0
  58. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/__init__.py +0 -0
  59. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/agent_protocol.py +0 -0
  60. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/canonical.py +0 -0
  61. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/formats.py +0 -0
  62. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/hosted_crawler_protocol.py +0 -0
  63. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/key_seam.py +0 -0
  64. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/page_coverage.py +0 -0
  65. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/part_check_evidence.py +0 -0
  66. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/session_probes.py +0 -0
  67. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/skill_assets.py +0 -0
  68. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/table_manifest.py +0 -0
  69. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/__init__.py +0 -0
  70. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/acquire.py +0 -0
  71. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/acquire_cancel.py +0 -0
  72. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/approvals.py +0 -0
  73. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/backoff.py +0 -0
  74. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/categories.py +0 -0
  75. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/commands.py +0 -0
  76. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/dataset-categories-v1.json +0 -0
  77. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/delivery.py +0 -0
  78. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/description_shape.py +0 -0
  79. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/download.py +0 -0
  80. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/enrichment.py +0 -0
  81. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/fast_features.py +0 -0
  82. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/matching_benchmark.py +0 -0
  83. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/narrative.py +0 -0
  84. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/parity.py +0 -0
  85. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/probe.py +0 -0
  86. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/progress_rate.py +0 -0
  87. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/progress_vocabulary.py +0 -0
  88. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/propose.py +0 -0
  89. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe.py +0 -0
  90. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe_brief.py +0 -0
  91. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe_explain.py +0 -0
  92. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe_forecast.py +0 -0
  93. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/recipe_lint.py +0 -0
  94. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/refresh_rehearsal.py +0 -0
  95. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/release_check.py +0 -0
  96. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/research.py +0 -0
  97. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/router.py +0 -0
  98. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/run_diagnose.py +0 -0
  99. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/run_prefix.py +0 -0
  100. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/stream.py +0 -0
  101. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/stream_venue.py +0 -0
  102. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/studio_capabilities.py +0 -0
  103. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/support.py +0 -0
  104. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/table_view.py +0 -0
  105. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/transport.py +0 -0
  106. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/user_agent.py +0 -0
  107. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_artifacts.py +0 -0
  108. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_catalog.py +0 -0
  109. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_connected.py +0 -0
  110. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_connections.py +0 -0
  111. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_dataset_covers.py +0 -0
  112. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_datasets.py +0 -0
  113. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_handoff.py +0 -0
  114. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_matching.py +0 -0
  115. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_narrative.py +0 -0
  116. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_public.py +0 -0
  117. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_query.py +0 -0
  118. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_reader.py +0 -0
  119. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_secrets.py +0 -0
  120. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_stream.py +0 -0
  121. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/v4_tables.py +0 -0
  122. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/thin/vocabulary.py +0 -0
  123. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/__init__.py +0 -0
  124. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/attendance.py +0 -0
  125. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/clarification.py +0 -0
  126. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/cloud_auth.py +0 -0
  127. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/commands/__init__.py +0 -0
  128. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/commands/auth.py +0 -0
  129. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/commands/clarify.py +0 -0
  130. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/commands/login.py +0 -0
  131. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/commands/whoami.py +0 -0
  132. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/credential_native.py +0 -0
  133. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/credential_store.py +0 -0
  134. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/credentials.py +0 -0
  135. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/login.py +0 -0
  136. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/path_kind.py +0 -0
  137. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/plain_file.py +0 -0
  138. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/src/mostlyright/data_harness/ux/remediation.py +0 -0
  139. {mostlyright_data-0.31.10 → mostlyright_data-0.31.12}/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.10
3
+ Version: 0.31.12
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/
@@ -146,7 +146,10 @@ column description.
146
146
 
147
147
  ## 6. Write the vocabulary column by column
148
148
 
149
- An alias maps a phrase to an exact filter, and a sort alias maps a phrase to a sort. List every
149
+ An alias maps a phrase to an exact filter, and a sort alias maps a phrase to a sort. Once
150
+ planning#1842 lands, the live page's search node applies the aliases and merges them with the
151
+ planner's plan. Until then aliases are honoured only by `search explain`, and only while the
152
+ planner is unavailable for that table. The live page applies none. List every
150
153
  declared column before you write the spec. Then generate its phrases from its type:
151
154
 
152
155
  | Column | Phrases | Spec entry |
@@ -191,7 +194,46 @@ column names, the descriptions and the planner. Never bind a word that two colum
191
194
  such as "growth", "change" or "best", unless the brief names the metric. "Highest growth"
192
195
  pointed at a revenue column turns "highest growth last 30 days" into a revenue sort.
193
196
 
194
- Studio caps a spec at 128 filter aliases and 64 sort aliases, and `search validate` refuses more.
197
+ ### Declare a floor for every rate
198
+
199
+ A growth, change, ratio or per-unit column ranks nonsense first when its base is tiny. Revenue
200
+ that grew from $5,000 to $80 million is 1,600,000% growth, and a film with three votes has a
201
+ perfect rating. Sorting by such a column shows shells and placeholders, not the rows the reader
202
+ asked for. For each sortable rate column, declare the column it is a rate of, and the smallest
203
+ base that makes the rate meaningful, in `order_floors` (contract 4.54.0, up to 32 entries):
204
+
205
+ ```json
206
+ "order_floors": [
207
+ {"column": "revenue_growth_pct", "base_column": "revenue_prior_year_usd", "min": 10000000},
208
+ {"column": "revenue_cagr_5y_pct", "base_column": "revenue_5y_ago_usd", "min": 10000000},
209
+ {"column": "vote_average", "base_column": "vote_count", "min": 100}
210
+ ]
211
+ ```
212
+
213
+ - `column` is in `sortable_columns` or is a sort alias's column. `base_column` is another numeric
214
+ column in `filterable_columns`. List each base early in `filterable_columns`: a package keeps 64
215
+ filters, role and alias columns first and the rest in spec order, and a base past that cut
216
+ loses its floor. `search validate TABLE --spec` names such a floor in `warnings` under
217
+ `/order_floors/`; treat that warning as a floor that will not apply.
218
+ - `min` is a whole number in the base column's own unit, written without a decimal point or
219
+ exponent: `10000000` on a column in dollars, `10` on a column in millions.
220
+ - Floors are declared on a search revision with `mr-data search configure --spec`, not in the
221
+ recipe's `table.search`, which refuses the member. A revision configured from the recipe's own
222
+ spec carries none, and a new version of a private table keeps them only when you configure the
223
+ revision again. A plan made without the planner, such as a keystroke search, applies no floor.
224
+ - A Studio older than contract 4.54.0 refuses the member as unknown. Configure without it there.
225
+ - When a plan orders by `column`, in either direction, Studio adds `base_column >= min` as a
226
+ filter. The reader sees it in the search summary and can remove it. A query that already filters
227
+ `base_column` keeps its own filter.
228
+ - Prefer the true base (the earlier period's revenue, the vote count) over a size proxy such as
229
+ market cap. When the table has no base column, add one in the transform: it is cheaper than a
230
+ wrong ranking.
231
+ - Choose `min` from the built table with `mr-data query`: the smallest base at which the rate stops
232
+ being dominated by placeholders. State the floor at the start of the base column's
233
+ description, since the planner reads only the first 160 characters of a description.
234
+
235
+ A recipe's spec takes 128 filter aliases and 64 sort aliases, and a search revision 512 and 256.
236
+ `search validate` refuses more.
195
237
  Rank the phrases and keep the top of the list. Put first the words that name the kind of row, the
196
238
  facets users type most, the popularity sorts and the product's own vocabulary. A phrase you leave
197
239
  out maps to nothing. The planner may still recover it from the column names and values, and
@@ -200,15 +242,35 @@ nothing guarantees that.
200
242
  ## 8. Turn the planner on
201
243
 
202
244
  The planner turns a sentence into filters, sorts and preferences. Turn it on for every table with
203
- search. With search on, the planner plans every settled query. Only a query whose every word an
204
- alias resolved skips it. Set `"planner": {"enabled": true, "external_context_allowed": true}` on
205
- a public table. Keep
206
- `external_context_allowed` false on a private table unless the user or the workspace policy
207
- authorizes sending its context to the planner model. Data-read permission is not that consent.
208
- Queries with negation or "or" stay unresolved rather than becoming a guessed filter.
209
-
210
- Write each column description as if the planner reads it. Say what the column holds, its unit,
211
- and the words people use for it.
245
+ search with `"enabled": true`. A query whose every word an alias resolved skips it. Queries with
246
+ negation or "or" stay unresolved rather than becoming a guessed filter.
247
+
248
+ The planner model sees the query text, the column names and their descriptions, value
249
+ dictionaries of at most 16 columns of at most 256 short values each, the aliases and the field
250
+ roles. It never sees rows.
251
+
252
+ `external_context_allowed` is the owner's consent to send that context to the planner model:
253
+
254
+ - A public table whose sources are all public gets the planner on its live page whatever the flag
255
+ says. Set the flag to `true` on it, so `search explain` and `capabilities.planner` match the page.
256
+ - A private or link-shared table gets the planner only when the recipe sets the flag to `true`.
257
+ That is a deliberate yes from the user or the workspace policy. Data-read permission is not that
258
+ consent. Without that yes, keep the flag `false`.
259
+ - Until the Studio change in planning#1844 is live, the flag changes only `search explain` and the
260
+ `capabilities.planner` readout. The live page plans a public table with public sources and no
261
+ other table.
262
+
263
+ `search inspect`, `search status` and `search explain` say why the planner is unavailable in
264
+ `planner_detail.reason`. `external_context_not_allowed` means the recipe withholds consent.
265
+ `private_source` means a source is not public.
266
+
267
+ Write each column description as if the planner reads it, because it does, and the page shows it
268
+ to the reader beside results ordered by that column. Say what the column holds, the period it
269
+ covers ("trailing twelve months", "latest fiscal year against the year before", "over the last
270
+ five fiscal years"), its unit, and the words people use for it, abbreviations included
271
+ ("compound annual growth rate, CAGR"). The planner picks the column a query asks about, named
272
+ bare ("revenue") or in a question ("what is the P/E ratio?"), from these descriptions. It reads
273
+ the first 160 characters of each, so lead with what the column measures and its period.
212
274
 
213
275
  ### Name and describe columns for search
214
276
 
@@ -314,7 +376,8 @@ mr-data search explain TABLE_ID --version VERSION_ID --query "stocks under 10" -
314
376
 
315
377
  Read `planner_used`, `filters` and `order_by`. A query that asks for a filter or a sort and plans
316
378
  text only is a gap in the spec. Add the alias or sharpen the column description, then check again.
317
- `planner_used: false` on a settled query is a fault to report, unless aliases resolved every word.
379
+ `planner_used: false` on a settled query is a fault to report, unless aliases resolved every word
380
+ or `planner_detail.reason` says the planner is withheld.
318
381
 
319
382
  Run real queries against the live search once `package_state` is `ready`:
320
383
 
@@ -43,7 +43,7 @@ Read `status`:
43
43
  | `recipe_test_cancelled` | Cancelled before it reported. | 2 |
44
44
  | `recipe_test_awaiting_confirmation` | Held for a size confirmation. Run `confirm_command`, then ask again. The hold is a size check, not a charge. | 2 |
45
45
  | `recipe_test_started` | `--no-wait` returned before the report. | 0 |
46
- | `recipe_test_still_running` | Not reported within the wait. Ask again with the same bounds. | 2 |
46
+ | `recipe_test_still_running` | Not reported within the wait. `test_status: queued` means no worker holds the test yet, so nothing is wrong with the recipe. Ask again with the same bounds. | 2 |
47
47
  | `recipe_test_skipped` | Nothing was tested. `test.reason` says why: `not_served` (Studio predates the test), `engine_unsupported` (the recipe runs on the Keel engine) or `worker_unsupported` (the worker predates the test and refused it `JOB_INVALID`). | 0 |
48
48
 
49
49
  Asking again with the same bounds reads a running test back, and a completed test replays its
@@ -66,8 +66,9 @@ page is fetched twice, and the receipt's `recipe_test` block names the test run.
66
66
  When the test finds a blocking problem, fails, is held or is still running after those five
67
67
  minutes, the full is not started. The status is `run_stopped_by_test`,
68
68
  `run_test_awaiting_confirmation` or `run_test_still_running`, `run_id` names the test, and the
69
- command exits 2. Running the same command again picks a running test back up, replays a finished
70
- one, and tests again after a failure. `next` ends with the command that starts the
69
+ command exits 2. `test.test_status: queued` on `run_test_still_running` means no worker had
70
+ picked the test up; `mr-data status RUN_ID` says when one has. Running the same command again
71
+ picks a running test back up, replays a finished one, and tests again after a failure. `next` ends with the command that starts the
71
72
  same full without the test. Repair the recipe first. Skip the test only when the user decides the
72
73
  problem does not matter:
73
74
 
@@ -50,7 +50,7 @@ from time import monotonic
50
50
  from typing import Any
51
51
 
52
52
  from mostlyright.data_harness.thin import THIN_SCHEMA_PREFIX
53
- from mostlyright.data_harness.thin.session import StudioSession, dataset_dashboard_url
53
+ from mostlyright.data_harness.thin.session import StudioSessionProvider, dataset_dashboard_url
54
54
  from mostlyright.data_harness.thin.transport import (
55
55
  REQUEST_TIMEOUT_SECONDS,
56
56
  ThinLaneError,
@@ -931,8 +931,9 @@ def _document_counts(document: Mapping[str, Any]) -> tuple[int, int]:
931
931
  #: One session per process, because two reports in one command -- ``recipe`` says one thing before
932
932
  #: it registers and another after -- must not buy two token exchanges. It is never shared with a
933
933
  #: command's own client: those open their own, and a cache reaching across the two would make a
934
- #: progress report able to break a command by holding an expired token.
935
- _SESSION: StudioSession | None = None
934
+ #: progress report able to break a command by holding an expired token. It is a provider, so the
935
+ #: clients built on it share one renewal when the token nears its expiry rather than one each.
936
+ _SESSION: StudioSessionProvider | None = None
936
937
 
937
938
  #: The verb's transport, which is cached because its clock is a constant. The automatic half's is
938
939
  #: not cached and cannot be: its clock is a deadline, and a deadline belongs to one report.
@@ -993,11 +994,11 @@ def _reporting_client(budget: _Budget) -> StudioV4ActivityClient:
993
994
 
994
995
  def _client_on(transport: Transport) -> StudioV4ActivityClient:
995
996
  global _SESSION
996
- from mostlyright.data_harness.thin.session import open_studio_session
997
+ from mostlyright.data_harness.thin.session import open_renewable_session
997
998
  from mostlyright.data_harness.ux.credentials import resolve_cloud_credentials
998
999
 
999
1000
  if _SESSION is None:
1000
- _SESSION = open_studio_session(resolve_cloud_credentials(), transport=transport)
1001
+ _SESSION = open_renewable_session(resolve_cloud_credentials(), transport=transport)
1001
1002
  return StudioV4ActivityClient(_SESSION, transport=transport)
1002
1003
 
1003
1004
 
@@ -49,6 +49,7 @@ from mostlyright.data_harness.thin.v4_runs import (
49
49
  CONFIRM_REQUIRED_CODE,
50
50
  TERMINAL_RUN_STATUSES,
51
51
  StudioV4RunClient,
52
+ attempt_progress_members,
52
53
  confirm_held_command,
53
54
  failure_sentence,
54
55
  failure_triple,
@@ -515,6 +516,12 @@ def reading(run: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
515
516
  #: under. It is not a status word: the status is still :data:`FAILED`, with ``room_fault: true``.
516
517
  ROOM_FAULT = "room_fault"
517
518
 
519
+ #: The sentence key for a test still ``queued`` when the wait ended: no worker holds it. It is not
520
+ #: a status word -- the status stays ``recipe_test_still_running`` / ``run_test_still_running`` --
521
+ #: it is which ``next`` and headline that status gets, because "running" was untrue of a test no
522
+ #: worker had picked up and sent people to look for a slow source rather than a cold queue.
523
+ QUEUED = "queued"
524
+
518
525
  #: What a person does next, by what the test came to.
519
526
  _NEXT = {
520
527
  PASSED: (
@@ -662,11 +669,29 @@ def _settled(
662
669
  "confirm_command": confirm_held_command(str(run["run_id"])),
663
670
  }
664
671
  if state not in TERMINAL_RUN_STATUSES:
672
+ minutes = int(ceiling // 60)
673
+ if state == QUEUED:
674
+ ledger = attempt_progress_members(client, str(run["run_id"]))
675
+ waiting = (
676
+ "it is back in the queue after an earlier attempt and no worker holds it now"
677
+ if "progress" in ledger
678
+ else "no worker has picked it up yet"
679
+ )
680
+ return {
681
+ "status": STILL_RUNNING,
682
+ "headline": f"The recipe test is still queued: {waiting}, so its report is not in.",
683
+ **members,
684
+ "test_status": state,
685
+ **ledger,
686
+ "next": f"The test was still queued after {minutes} minutes: {waiting}. Ask again "
687
+ "with the same bounds to pick the same test back up, or check whether a worker "
688
+ f"has claimed it with mr-data status {run['run_id']}.",
689
+ }
665
690
  return {
666
691
  "status": STILL_RUNNING,
667
692
  **members,
668
693
  "test_status": state,
669
- "next": f"The test had not reported after {int(ceiling // 60)} minutes. Ask "
694
+ "next": f"The test had not reported after {minutes} minutes. Ask "
670
695
  "again with the same bounds to pick it back up, or follow it with "
671
696
  f"mr-data watch {run['run_id']}.",
672
697
  }
@@ -786,18 +811,24 @@ def before_full(
786
811
  block = {"status": "passed", **members}
787
812
  block.pop("preflight_report", None)
788
813
  return BeforeFull(block=block, resume_capture_run_id=str(settled["run_id"]))
814
+ headline = members.pop("headline", None)
789
815
  test_block = {"status": status.removeprefix("recipe_test_"), **members}
790
- said = ROOM_FAULT if members.get("room_fault") is True else status
791
- return BeforeFull(
792
- stop=_stopped(
793
- status,
794
- {
795
- "run_id": str(settled["run_id"]),
796
- "test": test_block,
797
- "next": _STOPPED_NEXT[said] + _HELD_TAIL + rerun,
798
- },
816
+ if members.get("room_fault") is True:
817
+ said = ROOM_FAULT
818
+ elif status == STILL_RUNNING and members.get("test_status") == QUEUED:
819
+ said = QUEUED
820
+ else:
821
+ said = status
822
+ sentence = _STOPPED_NEXT[said]
823
+ stopped: dict[str, Any] = {"run_id": str(settled["run_id"]), "test": test_block}
824
+ if said == QUEUED:
825
+ # The test's own headline, said about the full it was standing in front of.
826
+ sentence = sentence.format(run=settled["run_id"])
827
+ stopped["headline"] = str(headline).replace(
828
+ "so its report is not in", "so the full run was not started"
799
829
  )
800
- )
830
+ stopped["next"] = sentence + _HELD_TAIL + rerun
831
+ return BeforeFull(stop=_stopped(status, stopped))
801
832
 
802
833
 
803
834
  _STOPPED_NEXT = {
@@ -814,6 +845,9 @@ _STOPPED_NEXT = {
814
845
  STILL_RUNNING: f"The recipe test had not reported after {int(FULL_WAIT_SECONDS // 60)} "
815
846
  "minutes, so the full was not started. Run this command again to pick the same test back "
816
847
  "up.",
848
+ QUEUED: f"The recipe test was still queued after {int(FULL_WAIT_SECONDS // 60)} minutes, "
849
+ "waiting for a worker, so the full was not started. Run this command again to pick the same "
850
+ "test back up; mr-data status {run} says whether a worker has claimed it.",
817
851
  }
818
852
 
819
853
 
@@ -847,6 +881,7 @@ __all__ = [
847
881
  "MAX_WAIT_SECONDS",
848
882
  "PASSED",
849
883
  "PREFLIGHT_COMPLETE",
884
+ "QUEUED",
850
885
  "ROOM_FAULT",
851
886
  "RUN_STOPPED_STATUS",
852
887
  "SKIPPED",
@@ -34,7 +34,11 @@ from mostlyright.data_harness.canonical import (
34
34
  canonical_json_bytes,
35
35
  parse_foreign_json,
36
36
  )
37
- from mostlyright.data_harness.thin.session import StudioSession
37
+ from mostlyright.data_harness.thin.session import (
38
+ StudioSession,
39
+ StudioSessionProvider,
40
+ sessions_for,
41
+ )
38
42
  from mostlyright.data_harness.thin.transport import (
39
43
  MAX_JSON_RESPONSE_BYTES,
40
44
  ThinLaneError,
@@ -211,15 +215,39 @@ class StudioApiClient:
211
215
  hardening, and a second ``_call`` would be a second place for the byte ceiling, the JSON
212
216
  hardening and the Studio-code translation to drift apart. Subclasses add routes; none of them
213
217
  adds a transport.
218
+
219
+ ⚠ AND EVERY CALL ASKS FOR ITS TOKEN FRESH (planning#1845). The client holds a
220
+ :class:`~mostlyright.data_harness.thin.session.StudioSessionProvider` rather than one token, so
221
+ a call made after a long wait -- the recipe test's report, a search poll, a closing read --
222
+ goes out on a token renewed when it had under a minute left, and a 401 on a token that was not
223
+ just renewed renews it once and sends the same call again, once. A session built by hand has
224
+ no renewal, and its 401 is final as it always was.
214
225
  """
215
226
 
216
- def __init__(self, session: StudioSession, *, transport: Transport | None = None) -> None:
217
- self._session = session
227
+ def __init__(
228
+ self,
229
+ session: StudioSession | StudioSessionProvider,
230
+ *,
231
+ transport: Transport | None = None,
232
+ ) -> None:
233
+ self._sessions = sessions_for(session)
218
234
  self._transport = transport or UrlLibTransport()
219
235
 
220
236
  @property
221
237
  def session(self) -> StudioSession:
222
- return self._session
238
+ """The session held right now; a call renews it first when it is nearly spent."""
239
+
240
+ return self._sessions.session
241
+
242
+ @property
243
+ def _session(self) -> StudioSession:
244
+ return self._sessions.session
245
+
246
+ @property
247
+ def sessions(self) -> StudioSessionProvider:
248
+ """The provider this client's calls renew through, for a stream that should share it."""
249
+
250
+ return self._sessions
223
251
 
224
252
  @property
225
253
  def transport(self) -> Transport:
@@ -251,7 +279,6 @@ class StudioApiClient:
251
279
  headers = {
252
280
  "Accept": "application/json",
253
281
  CLIENT_HEADER: CLIENT_NAME,
254
- **self._session.authorization(),
255
282
  }
256
283
  if extra_headers:
257
284
  headers.update(extra_headers)
@@ -282,10 +309,72 @@ class StudioApiClient:
282
309
  and len(raw_body) > max_request_bytes
283
310
  ):
284
311
  raise ThinLaneError("THIN_REQUEST_INVALID", "request exceeds its byte limit")
285
- url = f"{self._session.studio_base_url}{path}{_query_string(query)}"
312
+ # A renewal made for THIS call -- ahead of expiry -- means a 401 on it is not an expiry.
313
+ renewals_before = self._sessions.renewals
314
+ selected = self._sessions.current()
315
+ try:
316
+ return self._send(
317
+ selected,
318
+ method,
319
+ path,
320
+ query=query,
321
+ headers=headers,
322
+ raw_body=raw_body,
323
+ expected=expected,
324
+ shape=shape,
325
+ response_headers=response_headers,
326
+ maximum=maximum,
327
+ max_members=max_members,
328
+ )
329
+ except ThinLaneError as refusal:
330
+ if (
331
+ getattr(refusal, "http_status", None) != 401
332
+ or not self._sessions.renewable
333
+ or self._sessions.renewals != renewals_before
334
+ # A call that carried its own bearer was refused for THAT credential.
335
+ or "Authorization" in headers
336
+ ):
337
+ raise
338
+ # The token lapsed between the check and Studio reading it, or Studio holds a shorter
339
+ # clock than the expiry Cloud stated. Renew once and send the same call once more; a 401
340
+ # never reached a handler, so the call was not applied. A second 401 is final.
341
+ return self._send(
342
+ self._sessions.renew(),
343
+ method,
344
+ path,
345
+ query=query,
346
+ headers=headers,
347
+ raw_body=raw_body,
348
+ expected=expected,
349
+ shape=shape,
350
+ response_headers=response_headers,
351
+ maximum=maximum,
352
+ max_members=max_members,
353
+ )
354
+
355
+ def _send(
356
+ self,
357
+ session: StudioSession,
358
+ method: str,
359
+ path: str,
360
+ *,
361
+ query: Mapping[str, Any] | None,
362
+ headers: Mapping[str, str],
363
+ raw_body: bytes | None,
364
+ expected: tuple[int, ...],
365
+ shape: type,
366
+ response_headers: dict[str, str] | None,
367
+ maximum: int,
368
+ max_members: int,
369
+ ) -> Any:
370
+ """One request on one session's token, and the refusal it becomes."""
371
+
372
+ url = f"{session.studio_base_url}{path}{_query_string(query)}"
373
+ # The session's bearer first, so a call that states its own (a deciding credential) wins.
374
+ sent = {**session.authorization(), **headers}
286
375
  try:
287
376
  status, raw, answered_headers = self._transport.request(
288
- method, url, headers, raw_body, maximum
377
+ method, url, sent, raw_body, maximum
289
378
  )
290
379
  if response_headers is not None:
291
380
  response_headers.update(answered_headers)
@@ -22,6 +22,7 @@ import re
22
22
  import time
23
23
  from collections.abc import Callable, Mapping
24
24
  from dataclasses import dataclass
25
+ from dataclasses import field as dataclass_field
25
26
  from datetime import UTC, datetime, timedelta
26
27
  from typing import Any
27
28
  from urllib.parse import quote, urlencode
@@ -169,22 +170,43 @@ TOKEN_EXCHANGE_MAX_RETRY_SECONDS = 30.0
169
170
  #: The spread is never more than the wait itself, and never subtracted from it.
170
171
  TOKEN_EXCHANGE_MAX_JITTER_SECONDS = 5.0
171
172
 
172
- #: How close to its expiry a Studio token is traded for a new one before a long-lived stream
173
- #: opens its next connection. A token that would expire mid-handshake is not worth sending.
173
+ #: How close to its expiry a Studio token is traded for a new one before the next call or the next
174
+ #: stream connection goes out. A token that would expire mid-call is not worth sending.
174
175
  SESSION_RENEWAL_MARGIN_SECONDS = 60.0
175
176
 
176
177
  _CLI_KEY = re.compile(r"^mr_cli_[A-Za-z0-9_-]{64}$")
177
178
 
178
179
 
180
+ @dataclass(frozen=True)
181
+ class SessionRenewal:
182
+ """How one session is traded for a fresh one: the device-key exchange, and the clock it reads.
183
+
184
+ Held by a session :func:`open_studio_session` minted, so every client built on that session can
185
+ renew it without each command threading the credential through by hand. It closes over the
186
+ device key already in this process's memory for the first exchange; nothing here writes it.
187
+ """
188
+
189
+ exchange: Callable[[], StudioSession]
190
+ now: Callable[[], datetime]
191
+
192
+ def __repr__(self) -> str:
193
+ return "SessionRenewal(<device-key exchange>)"
194
+
195
+
179
196
  @dataclass(frozen=True)
180
197
  class StudioSession:
181
- """One short-lived Studio authority: where to call, as whom, and until when."""
198
+ """One short-lived Studio authority: where to call, as whom, and until when.
199
+
200
+ ``renewal`` is how to trade it for a fresh one, and ``None`` for a session that can never be
201
+ renewed -- one a test or a caller built by hand. It takes no part in equality or the repr.
202
+ """
182
203
 
183
204
  studio_base_url: str
184
205
  workspace_id: UUID
185
206
  token: str
186
207
  expires_at: datetime
187
208
  cloud_url: str
209
+ renewal: SessionRenewal | None = dataclass_field(default=None, compare=False, repr=False)
188
210
 
189
211
  def __repr__(self) -> str:
190
212
  return (
@@ -358,7 +380,12 @@ def open_studio_session(
358
380
  now: Callable[[], datetime] = lambda: datetime.now(UTC),
359
381
  sleep: Callable[[float], None] = time.sleep,
360
382
  ) -> StudioSession:
361
- """Exchange the stored device key for a short-lived Studio token."""
383
+ """Exchange the stored device key for a short-lived Studio token.
384
+
385
+ The session that comes back carries its own :class:`SessionRenewal` -- this same exchange,
386
+ made again -- so a client built on it renews the token ahead of its expiry and once on a 401
387
+ (see :class:`StudioSessionProvider`) rather than outliving it.
388
+ """
362
389
 
363
390
  require_device_key(credentials)
364
391
  cloud_url = service_origin(credentials.cloud_url, "Cloud URL", allow_loopback_http=False)
@@ -412,6 +439,10 @@ def open_studio_session(
412
439
  expires_at = _timestamp(_text(parsed, "expires_at"), "expires_at")
413
440
  if expires_at <= now():
414
441
  raise ThinLaneError("THIN_TOKEN_EXCHANGE_FAILED", "Cloud returned an expired Studio token")
442
+
443
+ def _again() -> StudioSession:
444
+ return open_studio_session(credentials, transport=transport, now=now, sleep=sleep)
445
+
415
446
  return StudioSession(
416
447
  studio_base_url=service_origin(
417
448
  _text(parsed, "studio_base_url"), "Studio base URL", allow_loopback_http=True
@@ -420,28 +451,33 @@ def open_studio_session(
420
451
  token=_text(parsed, "token"),
421
452
  expires_at=expires_at,
422
453
  cloud_url=cloud_url,
454
+ renewal=SessionRenewal(exchange=_again, now=now),
423
455
  )
424
456
 
425
457
 
426
458
  class StudioSessionProvider:
427
- """The Studio session a long-lived command reads through, renewed rather than outlived.
428
-
429
- ⚠ THIS IS THE ONE PLACE THE "ONE EXCHANGE PER COMMAND" RULE BENDS, AND ONLY FOR STREAMS.
430
- Every other command in this lane trades the device key once and finishes long before the
431
- token expires. A ``watch`` does not: a Studio token lives for minutes and a run streams for
432
- hours, and a stream that reconnects across a Cloud Run cut with the token it started with is
433
- refused the moment that token lapses -- which surfaced as an opaque authorization refusal in
434
- the middle of a build that was still running. So a stream asks this provider for its session
435
- before every connection, and the provider re-exchanges the device key when the token has less
436
- than :data:`SESSION_RENEWAL_MARGIN_SECONDS` left, or once when Studio answers 401.
437
-
438
- ⚠ A PROVIDER WITH NO ``exchange`` NEVER RENEWS. A test, or a caller handed only a session,
439
- gets exactly the behaviour it had: the session it was given, and a 401 that is final.
459
+ """The Studio session a command calls through, renewed rather than outlived.
460
+
461
+ ⚠ A STUDIO TOKEN LIVES FOR MINUTES AND A WAIT CAN LAST LONGER. A ``watch`` streams for hours;
462
+ ``run --full`` waits up to five minutes for the recipe test in front of it, and ``recipe
463
+ test`` twenty; the search and query polls wait on a node that is still settling. Each of those
464
+ used to make its follow-up call on the token minted at the start, and Studio refused it with
465
+ ``401 AUTHENTICATION_REQUIRED`` -- which surfaced as an authorization refusal in the middle of
466
+ a wait that was going fine (planning#1845). So every Studio call
467
+ (:class:`~mostlyright.data_harness.thin.runs.StudioApiClient`) and every stream connection asks
468
+ a provider for its session first, and the provider re-exchanges the device key when the token
469
+ has less than :data:`SESSION_RENEWAL_MARGIN_SECONDS` left. A 401 on a token that was not just
470
+ renewed renews once more and the caller sends the same call again, once; a second 401 is final.
471
+
472
+ ⚠ A PROVIDER WITH NO ``exchange`` NEVER RENEWS. A test, or a caller handed only a session it
473
+ built by hand, gets exactly the behaviour it had: the session it was given, and a 401 that is
474
+ final. A session :func:`open_studio_session` minted carries its exchange, and
475
+ :func:`sessions_for` hands it to the provider.
440
476
 
441
477
  ⚠ AND A RENEWAL MUST NAME THE SAME STUDIO AND THE SAME WORKSPACE. A stream resumes from a
442
- ``Last-Event-ID`` in one Studio's cursor space; a fresh token for another origin or another
443
- workspace would carry that cursor somewhere it means nothing, so the renewal is refused by name
444
- rather than followed.
478
+ ``Last-Event-ID`` in one Studio's cursor space, and a wait reads back a run in one workspace; a
479
+ fresh token for another origin or another workspace would carry that cursor or that run
480
+ somewhere it means nothing, so the renewal is refused by name rather than followed.
445
481
 
446
482
  The device key the exchange closes over is the one already in this process's memory for the
447
483
  first exchange. Nothing here writes a token anywhere.
@@ -472,10 +508,22 @@ class StudioSessionProvider:
472
508
  return self._exchange is not None
473
509
 
474
510
  def current(self) -> StudioSession:
475
- """The session to open the next connection with, renewed first when it is nearly spent."""
511
+ """The session to open the next connection with, renewed first when it is nearly spent.
512
+
513
+ ⚠ A RENEWAL AHEAD OF EXPIRY IS A COURTESY, NOT A GATE. When Cloud cannot mint a new token
514
+ right now (a cold-start 503, a 429, a 500) and the token held has not expired yet, the
515
+ call goes out on the token held -- which is what it did before renewal existed. Only a
516
+ token that has already lapsed makes the failed exchange the answer. A renewal that names
517
+ another Studio or workspace is refused either way.
518
+ """
476
519
 
477
520
  if self._exchange is not None and self._session.expires_at - self._now() < self._margin:
478
- return self.renew()
521
+ try:
522
+ return self.renew()
523
+ except ThinLaneError as refusal:
524
+ lapsed = self._session.expires_at <= self._now()
525
+ if refusal.code == "THIN_SESSION_BINDING" or lapsed:
526
+ raise
479
527
  return self._session
480
528
 
481
529
  def renew(self) -> StudioSession:
@@ -508,20 +556,25 @@ def open_renewable_session(
508
556
  now: Callable[[], datetime] = lambda: datetime.now(UTC),
509
557
  sleep: Callable[[float], None] = time.sleep,
510
558
  ) -> StudioSessionProvider:
511
- """Exchange the device key once now, and keep the means to exchange it again for a stream."""
512
-
513
- def _exchange() -> StudioSession:
514
- return open_studio_session(credentials, transport=transport, now=now, sleep=sleep)
559
+ """Exchange the device key once now, and keep the means to exchange it again."""
515
560
 
516
- return StudioSessionProvider(_exchange(), exchange=_exchange, now=now)
561
+ return sessions_for(open_studio_session(credentials, transport=transport, now=now, sleep=sleep))
517
562
 
518
563
 
519
564
  def sessions_for(session: StudioSession | StudioSessionProvider) -> StudioSessionProvider:
520
- """A provider for what a caller holds: itself, or a session that will never be renewed."""
565
+ """A provider for what a caller holds: itself, or one over the session and its own renewal.
566
+
567
+ A session :func:`open_studio_session` minted renews through the exchange it carries; one built
568
+ by hand carries none and is never renewed.
569
+ """
521
570
 
522
571
  if isinstance(session, StudioSessionProvider):
523
572
  return session
524
- return StudioSessionProvider(session)
573
+ if session.renewal is None:
574
+ return StudioSessionProvider(session)
575
+ return StudioSessionProvider(
576
+ session, exchange=session.renewal.exchange, now=session.renewal.now
577
+ )
525
578
 
526
579
 
527
580
  #: What a stated refusal code has to look like before it is interpolated into one.
@@ -960,6 +1013,7 @@ __all__ = [
960
1013
  "USAGE_LIMIT_SCHEMA",
961
1014
  "CloudClient",
962
1015
  "CloudRouteAbsent",
1016
+ "SessionRenewal",
963
1017
  "StudioSession",
964
1018
  "StudioSessionProvider",
965
1019
  "approval_dashboard_url",