mostlyright-data 0.20.0__tar.gz → 0.20.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/PKG-INFO +13 -1
  2. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/README.md +12 -0
  3. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/pyproject.toml +1 -1
  4. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/skills/mr-data-build/SKILL.md +23 -7
  5. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/recipe.py +53 -45
  6. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/commands/auth.py +83 -18
  7. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/commands/login.py +5 -3
  8. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/commands/whoami.py +1 -1
  9. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/credential_native.py +30 -15
  10. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/credential_store.py +1 -7
  11. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/credentials.py +15 -15
  12. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/login.py +15 -1
  13. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/remediation.py +38 -2
  14. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/.gitignore +0 -0
  15. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/scripts/hatch_build.py +0 -0
  16. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/skills/mr-data-build/agents/openai.yaml +0 -0
  17. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/skills/mr-data-build/scripts/write_research_notebook.py +0 -0
  18. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/__init__.py +0 -0
  19. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/canonical.py +0 -0
  20. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/formats.py +0 -0
  21. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/hosted_crawler_protocol.py +0 -0
  22. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/key_seam.py +0 -0
  23. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/page_coverage.py +0 -0
  24. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/part_check_evidence.py +0 -0
  25. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/session_probes.py +0 -0
  26. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/skill_assets.py +0 -0
  27. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/table_manifest.py +0 -0
  28. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/__init__.py +0 -0
  29. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/acquire.py +0 -0
  30. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/acquire_cancel.py +0 -0
  31. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/activity.py +0 -0
  32. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/approvals.py +0 -0
  33. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/categories.py +0 -0
  34. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/commands.py +0 -0
  35. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/dataset-categories-v1.json +0 -0
  36. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/download.py +0 -0
  37. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/narrative.py +0 -0
  38. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/parity.py +0 -0
  39. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/probe.py +0 -0
  40. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/progress_vocabulary.py +0 -0
  41. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/propose.py +0 -0
  42. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/research.py +0 -0
  43. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/router.py +0 -0
  44. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/runs.py +0 -0
  45. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/session.py +0 -0
  46. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/stream.py +0 -0
  47. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/stream_venue.py +0 -0
  48. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/transport.py +0 -0
  49. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/user_agent.py +0 -0
  50. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4.py +0 -0
  51. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_artifacts.py +0 -0
  52. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_catalog.py +0 -0
  53. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_connections.py +0 -0
  54. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_dataset_covers.py +0 -0
  55. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_datasets.py +0 -0
  56. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_handoff.py +0 -0
  57. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_narrative.py +0 -0
  58. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_query.py +0 -0
  59. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_runs.py +0 -0
  60. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_secrets.py +0 -0
  61. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_stream.py +0 -0
  62. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/v4_tables.py +0 -0
  63. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/thin/vocabulary.py +0 -0
  64. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/__init__.py +0 -0
  65. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/attendance.py +0 -0
  66. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/clarification.py +0 -0
  67. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/cloud_auth.py +0 -0
  68. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/commands/__init__.py +0 -0
  69. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/commands/clarify.py +0 -0
  70. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/path_kind.py +0 -0
  71. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/src/mostlyright/data_harness/ux/plain_file.py +0 -0
  72. {mostlyright_data-0.20.0 → mostlyright_data-0.20.2}/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.20.0
3
+ Version: 0.20.2
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/
@@ -48,6 +48,18 @@ mr-data whoami
48
48
 
49
49
  `login` stores a device key on the current machine. `whoami` checks that key.
50
50
 
51
+ New macOS/Linux logins use `~/.mostlyright/credentials.secure`, an unencrypted file restricted to
52
+ your OS account (0600 inside a 0700 directory). Processes running as you and backups can read it.
53
+ Older file-based logins migrate to this file without requesting Keychain access. Windows keeps
54
+ DPAPI encryption as its default.
55
+ Existing Keychain, DPAPI, or Secret Service logins keep their selected store; an unavailable store
56
+ never causes an automatic switch to file storage.
57
+
58
+ If an existing macOS login requests Keychain access, it is requesting the `md.mostlyright.cli`
59
+ device credential. To switch that login to file storage, restore access to its Keychain item,
60
+ run `mr-data auth logout` successfully, then run `mr-data login`. The `--credential-store secure-file`
61
+ option does not move an existing native-store login.
62
+
51
63
  ## Check the installation
52
64
 
53
65
  ```bash
@@ -36,6 +36,18 @@ mr-data whoami
36
36
 
37
37
  `login` stores a device key on the current machine. `whoami` checks that key.
38
38
 
39
+ New macOS/Linux logins use `~/.mostlyright/credentials.secure`, an unencrypted file restricted to
40
+ your OS account (0600 inside a 0700 directory). Processes running as you and backups can read it.
41
+ Older file-based logins migrate to this file without requesting Keychain access. Windows keeps
42
+ DPAPI encryption as its default.
43
+ Existing Keychain, DPAPI, or Secret Service logins keep their selected store; an unavailable store
44
+ never causes an automatic switch to file storage.
45
+
46
+ If an existing macOS login requests Keychain access, it is requesting the `md.mostlyright.cli`
47
+ device credential. To switch that login to file storage, restore access to its Keychain item,
48
+ run `mr-data auth logout` successfully, then run `mr-data login`. The `--credential-store secure-file`
49
+ option does not move an existing native-store login.
50
+
39
51
  ## Check the installation
40
52
 
41
53
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mostlyright-data"
3
- version = "0.20.0"
3
+ version = "0.20.2"
4
4
  description = "Mostly Right hosted CLI for reviewed datasets"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -252,6 +252,13 @@ machine that is only using the product installs from `pip` instead.
252
252
 
253
253
  ## Cloud authentication preflight
254
254
 
255
+ New and legacy file-based logins on macOS/Linux use an owner-only, unencrypted private file.
256
+ Windows keeps DPAPI encryption by default. Existing native-store logins retain their backend. Never bypass a Keychain/DPAPI/Secret Service refusal by
257
+ selecting secure-file: that flag cannot migrate an existing native-store login. Restore access,
258
+ then use a successful remote-first logout followed by login when the user wants file storage.
259
+ An interrupted sign-in can retain a pending record; follow its recovery instruction instead of
260
+ repeatedly starting another login.
261
+
255
262
  Authentication begins only when the next command will contact the Mostly Right cloud. Immediately
256
263
  before hosted `mr-data dataset create` or `mr-data recipe`, run
257
264
  `mr-data whoami --json` and read its `status`, and it intentionally does not report
@@ -933,16 +940,25 @@ finished work rather than a shape test. Where a source was never opened, and its
933
940
  a guess, two thousand rows a source is the fallback: it exercises every column, every cast and
934
941
  every declared check, and it comes back while the person who asked is still watching the page.
935
942
 
936
- **What bounds a source is how much it DECODES, not how much it weighs.** One confined decode may
937
- hand back 6,845,533 bytes, and a source over that is refused with `READER_BUDGET`, which names both
938
- sizes. The two quantities come apart constantly: a 10.4 MB document projected to a few columns
939
- decodes small and runs, while an 8.6 MB table read whole does not, and two members of one 3.6 MB
940
- archive land on opposite sides. So the fetched size predicts nothing on its own.
943
+ **What bounds a source is how much it DECODES, not how much it weighs.** The two come apart
944
+ constantly: a 10.4 MB document projected to a few columns decodes to a few hundred kilobytes,
945
+ while an 8.6 MB table read whole decodes to 8.6 MB. So the fetched size predicts nothing on its
946
+ own, and a source over either bound is refused with `READER_BUDGET`, which names both sizes.
947
+
948
+ **How far a source may fetch depends on how it will be read.** A Reader-pinned source, and an
949
+ unpinned `csv` one, fetch up to 256 MiB. An unpinned `ndjson` source fetches up to what one
950
+ confined request carries, about 36 MB. An unpinned `json` or `parquet` source keeps 16 MiB,
951
+ because neither has a row-at-a-time reader and both are read whole inside the clean room —
952
+ pinning a Reader is the way past that. What a source may DECODE to is its Reader family's output
953
+ budget, narrowed to at most 1 GiB, because the whole decoded relation is held in memory while it
954
+ is classified and sealed. A wide source is still best narrowed by projection, which is what
955
+ shrinks the decoded relation; a long source can still be split.
941
956
 
942
957
  `limits.max_source_bytes` states where fetching STOPS rather than how big the source is, so
943
958
  `mr-data recipe` does not refuse a large one. It registers the document and puts one sentence on
944
- the receipt under `warnings` when the declared number is larger than any decode could return. Read
945
- that as "go and check this one", not as a failure.
959
+ the receipt under `warnings` when the declared number is larger than a fetch can reach, and the
960
+ sentence names the formats that stop earlier. Read that as "go and check this one", not as a
961
+ failure.
946
962
 
947
963
  **Which remedy applies depends on which way the source is too big, and lowering the ceiling is
948
964
  never it** — that does not fetch less data, it truncates the sample or refuses the full run. A WIDE
@@ -67,26 +67,20 @@ REQUIRED_DOCUMENT_MEMBERS: tuple[str, ...] = (
67
67
  "timezone",
68
68
  )
69
69
 
70
- #: The declared ``limits.max_source_bytes`` above which one source is worth a word, in bytes.
71
- #:
72
- #: ⚠ THIS NUMBER BOUNDS THE FETCH, AND WHAT REFUSES IS THE DECODE. The two are different
73
- #: quantities and the old value conflated them. 3 MiB came from two observations -- a source that
74
- #: delivered 5.76 MB and died, one that delivered 1.15 MB and lived -- and neither was a
75
- #: measurement of anything the run enforces. Measured properly: a 10,449,878-byte JSON source
76
- #: projected to five columns SUCCEEDS, while an 8,578,215-byte CSV read whole FAILS, and two
77
- #: members of ONE 3.6 MB archive land on opposite sides -- so the fetched size, which is what this
78
- #: member states, cannot order the outcomes at all.
79
- #:
80
- #: WHAT ORDERS THEM is how much the Reader DECODES, because the confined decode hands its output
81
- #: back over a bounded channel and carries it twice there: base64 and as parsed rows. That
82
- #: ceiling is ``DELIVERABLE_READER_OUTPUT_BYTES`` on the backend, 6,845,533 bytes, and a source
83
- #: over it is refused by name with ``READER_BUDGET`` naming both sizes.
84
- #:
85
- #: So this stays a warning about a DECLARED ceiling being implausibly large, which is worth one
86
- #: sentence, and it no longer promises to predict a failure it cannot see. It is set at the
87
- #: backend's own output ceiling: a document declaring more than one decode can return is a
88
- #: document whose author has not measured the source, whatever the source turns out to weigh.
89
- READER_SOURCE_BYTE_CEILING = 6_845_533
70
+ #: The declared ``limits.max_source_bytes`` above which one source is worth a word, in bytes --
71
+ #: for a source that is read through descriptors, which is a Reader-pinned one or an unpinned
72
+ #: ``csv`` one. That is the hosted fetch cap.
73
+ READER_SOURCE_BYTE_CEILING = 268_435_456
74
+
75
+ #: The same ceiling for an unpinned ``ndjson`` source. It has an inline streaming pass and no
76
+ #: descriptor-backed one, so its bytes travel inside one confined request and what bounds the
77
+ #: fetch is what that request can carry.
78
+ NDJSON_SOURCE_BYTE_CEILING = 37_745_664
79
+
80
+ #: And for an unpinned ``json`` or ``parquet`` source, which has no streaming pass at all: both
81
+ #: are read whole inside the clean room, so the fetch keeps the drained read's tighter bound.
82
+ #: Pinning a Reader is what moves a source off this ceiling.
83
+ DRAINED_SOURCE_BYTE_CEILING = 16_777_216
90
84
 
91
85
  #: How many undescribed columns the warning names before it counts the rest. A table plan admits
92
86
  #: 512 columns, and a sentence that spelt every one of them out would be the wall the `numbered`
@@ -194,24 +188,16 @@ def check_document(document: dict[str, Any]) -> None:
194
188
 
195
189
 
196
190
  def source_byte_warnings(sources: Any) -> list[str]:
197
- """One sentence per source whose declared ceiling is larger than one Clean room reliably holds.
191
+ """One sentence per source whose declared ceiling is larger than the hosted lane fetches.
198
192
 
199
193
  ⚠ WARNINGS, AND NOT A GATE, FOR THE REASON :data:`READER_SOURCE_BYTE_CEILING` GIVES: the
200
194
  declared number is where fetching STOPS, not how big the source is. A gate over it refuses
201
- recipes that run -- three of this repository's own acceptance documents among them -- and still
202
- admits a source that declares 1 MiB and delivers 6. So nothing here decides anything; it hands
203
- the agent that wrote the number the measurement it would otherwise learn from a run that died.
204
-
205
- ⚠ THE LARGEST SOURCE DECIDES, NOT THE SUM. One Reader opens one source at a time inside one
206
- Clean room, so eight 2 MiB sources are worth nothing and one 6 MiB source is worth a sentence.
207
- A warning over the total would shout at recipes that run and stay quiet about the one that
208
- will not.
195
+ recipes that run and still admits a source that declares 1 MiB and delivers 6. So nothing here
196
+ decides anything; it tells the agent that wrote the number that the hosted lane will not honour
197
+ it.
209
198
 
210
- ⚠ AND THE SENTENCE NAMES THE FIX, which is the whole reason it is written here rather than left
211
- to a mid-run refusal. It also says what the fix is NOT: a smaller declared ceiling, which
212
- truncates the fetch rather than decoding less. Narrowing what the reader projects is the answer
213
- when the source is wide; splitting the source is the answer when it is long. Splitting a wide
214
- source does nothing, which is why the two cases are named separately.
199
+ ⚠ THE LARGEST SOURCE DECIDES, NOT THE SUM. One source is fetched at a time, so the sentence is
200
+ about the one that overstates its ceiling rather than about the total.
215
201
 
216
202
  A declared ceiling that is not a whole number of bytes is left alone: the document admits no
217
203
  fractional number at all, ``read_document`` has already refused one, and re-deciding here what
@@ -228,25 +214,45 @@ def source_byte_warnings(sources: Any) -> list[str]:
228
214
  if not isinstance(limits, Mapping):
229
215
  continue
230
216
  declared = limits.get("max_source_bytes")
231
- if type(declared) is not int or declared <= READER_SOURCE_BYTE_CEILING:
217
+ if type(declared) is not int:
218
+ continue
219
+ ceiling, why = _source_byte_ceiling(source)
220
+ if declared <= ceiling:
232
221
  continue
233
222
  named = source.get("name")
234
223
  where = f"{named!r}" if isinstance(named, str) and named else f"sources[{index}]"
235
224
  said.append(
236
- f"source {where} declares limits.max_source_bytes of {declared:,} bytes, which is "
237
- f"more than one confined decode can hand back ({READER_SOURCE_BYTE_CEILING:,}). That "
238
- "is a ceiling on fetching rather than a measurement of the source, so this registers "
239
- "and runs. What decides the run is how much the Reader DECODES, not how much is "
240
- "fetched: a wide document projected to a few columns decodes small however large it "
241
- "arrives, and a compact archive member can decode past the ceiling. A source that "
242
- "does is refused by name with READER_BUDGET, which states both sizes. Lowering this "
243
- "ceiling does not help, because it bounds the fetch and truncates rather than "
244
- "decoding less. Narrow the projection, or split the source and union the parts in a "
245
- "first transform step"
225
+ f"source {where} declares limits.max_source_bytes of {declared:,} bytes, and {why} "
226
+ f"stops at {ceiling:,}. That is a ceiling on fetching rather than a measurement of "
227
+ "the source, so this registers and runs, and the fetch stops there whatever the "
228
+ "document says. An unpinned source that answers ndjson stops at "
229
+ f"{NDJSON_SOURCE_BYTE_CEILING:,} and one that answers json or parquet at "
230
+ f"{DRAINED_SOURCE_BYTE_CEILING:,}, and pinning a Reader is what moves it off "
231
+ "those. Declare what the source actually weighs"
246
232
  )
247
233
  return said
248
234
 
249
235
 
236
+ def _source_byte_ceiling(source: Mapping[str, Any]) -> tuple[int, str]:
237
+ """The fetch ceiling one declared source is held to, and the phrase that names it.
238
+
239
+ A document declares a Reader pin on ``connector.parameters`` and declares no wire format at
240
+ all: the format is what the response turns out to be. So the one thing this client can read
241
+ off the document is whether the source is pinned. A pinned source fetches to the hosted cap
242
+ whatever its wire format. An unpinned source is held to the same cap here, and the sentence
243
+ says which formats stop earlier, because that is decided by bytes this client has not seen.
244
+ """
245
+
246
+ connector = source.get("connector")
247
+ parameters = connector.get("parameters") if isinstance(connector, Mapping) else None
248
+ if isinstance(parameters, list) and any(
249
+ isinstance(parameter, Mapping) and parameter.get("name") == "reader.family_id"
250
+ for parameter in parameters
251
+ ):
252
+ return READER_SOURCE_BYTE_CEILING, "a Reader-pinned source's fetch"
253
+ return READER_SOURCE_BYTE_CEILING, "an unpinned source's fetch"
254
+
255
+
250
256
  def columns_without_description(table: Any) -> list[str]:
251
257
  """The declared columns carrying no description, in the order the document declares them.
252
258
 
@@ -469,8 +475,10 @@ def recipe(args: argparse.Namespace, *, client: StudioV4Client | None = None) ->
469
475
 
470
476
  __all__ = [
471
477
  "COMMAND_HELP",
478
+ "DRAINED_SOURCE_BYTE_CEILING",
472
479
  "MAX_DOCUMENT_BYTES",
473
480
  "NAMED_UNDESCRIBED_COLUMNS",
481
+ "NDJSON_SOURCE_BYTE_CEILING",
474
482
  "READER_SOURCE_BYTE_CEILING",
475
483
  "REGISTER_SCHEMA",
476
484
  "REQUIRED_DOCUMENT_MEMBERS",
@@ -48,7 +48,9 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
48
48
  dest="auth_command", required=True, help="device-authority operation"
49
49
  )
50
50
  nested_json = argparse.ArgumentParser(add_help=False)
51
- nested_json.add_argument("--json", action="store_true", help=argparse.SUPPRESS)
51
+ nested_json.add_argument(
52
+ "--json", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS
53
+ )
52
54
 
53
55
  status = subcommands.add_parser(
54
56
  "status", parents=[nested_json], help="validate the effective device credential"
@@ -56,7 +58,7 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
56
58
  status.add_argument(
57
59
  "--credential-store",
58
60
  choices=("secure-file",),
59
- help="explicitly use the secure-file fallback for first credential migration",
61
+ help="select private-file storage for legacy credential migration",
60
62
  )
61
63
  status.set_defaults(auth_operation="status")
62
64
 
@@ -95,7 +97,9 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
95
97
  )
96
98
  login.add_argument("--device-name", help="what to call this device in Cloud")
97
99
  login.add_argument(
98
- "--credential-store", choices=("secure-file",), help="explicit secure-file store"
100
+ "--credential-store",
101
+ choices=("secure-file",),
102
+ help="select private-file storage; cannot change an existing native store",
99
103
  )
100
104
  login.set_defaults(auth_operation="login")
101
105
  rotate = subcommands.add_parser(
@@ -103,7 +107,9 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
103
107
  )
104
108
  rotate.add_argument("--device-name", help="what to call the replacement device")
105
109
  rotate.add_argument(
106
- "--credential-store", choices=("secure-file",), help="explicit secure-file store"
110
+ "--credential-store",
111
+ choices=("secure-file",),
112
+ help="select private-file storage; cannot change an existing native store",
107
113
  )
108
114
  rotate.set_defaults(auth_operation="rotate")
109
115
  logout = subcommands.add_parser(
@@ -117,7 +123,9 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
117
123
  help="remove local state without claiming remote revocation",
118
124
  )
119
125
  logout.add_argument(
120
- "--credential-store", choices=("secure-file",), help="explicit secure-file store"
126
+ "--credential-store",
127
+ choices=("secure-file",),
128
+ help="select private-file storage; cannot change an existing native store",
121
129
  )
122
130
  logout.set_defaults(auth_operation="logout")
123
131
  recover = subcommands.add_parser(
@@ -131,7 +139,9 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
131
139
  help="clear only a stale login lease; remote mint remains unproved",
132
140
  )
133
141
  recover.add_argument(
134
- "--credential-store", choices=("secure-file",), help="explicit secure-file store"
142
+ "--credential-store",
143
+ choices=("secure-file",),
144
+ help="select private-file storage; cannot change an existing native store",
135
145
  )
136
146
  recover.set_defaults(auth_operation="recover")
137
147
 
@@ -145,9 +155,17 @@ def run(args: argparse.Namespace) -> dict[str, Any]:
145
155
  if operation == "devices_revoke":
146
156
  return device_revoke_payload(args.key_id)
147
157
  if operation == "login":
148
- return login_payload(device_name=args.device_name, credential_store=args.credential_store)
158
+ return login_payload(
159
+ device_name=args.device_name,
160
+ credential_store=args.credential_store,
161
+ announce=not args.json,
162
+ )
149
163
  if operation == "rotate":
150
- return rotate_payload(device_name=args.device_name, credential_store=args.credential_store)
164
+ return rotate_payload(
165
+ device_name=args.device_name,
166
+ credential_store=args.credential_store,
167
+ announce=not args.json,
168
+ )
151
169
  if operation == "logout":
152
170
  return logout_payload(local_only=args.local_only, credential_store=args.credential_store)
153
171
  if operation == "recover":
@@ -491,6 +509,19 @@ def recover_payload(
491
509
  with credential_lock(store.config_dir):
492
510
  envelope = store.load_under_held_lock()
493
511
  if envelope is None:
512
+ if local_only:
513
+ # Resume a crash after deleting an empty pending store but before
514
+ # removing its selector. Never claim remote cleanup from local absence.
515
+ remove_credential_manifest_under_held_lock(path)
516
+ return _with_environment_authority(
517
+ {
518
+ "schema_version": _RECOVERY_SCHEMA,
519
+ "status": "local_recovery_completed",
520
+ "remote_revocation": "unproved",
521
+ "local_state": "removed",
522
+ "mint_outcome": "unproved",
523
+ }
524
+ )
494
525
  return _mutation_error(
495
526
  _RECOVERY_SCHEMA,
496
527
  status="refused",
@@ -516,16 +547,37 @@ def recover_payload(
516
547
  )
517
548
  if envelope.state == "login_pending":
518
549
  if not local_only:
519
- return _mutation_error(
520
- _RECOVERY_SCHEMA, status="refused", local_state="pending"
550
+ return {
551
+ **_mutation_error(
552
+ _RECOVERY_SCHEMA, status="refused", local_state="pending"
553
+ ),
554
+ "next_action": "Finish or stop the previous sign-in. If it has stopped, "
555
+ "run mr-data auth recover --local-only, then retry logout or login. "
556
+ "Local recovery does not prove remote revocation.",
557
+ }
558
+ # The outer lock already owns this generation. Calling the standalone
559
+ # recovery helper here would try to acquire the same non-reentrant lock.
560
+ if envelope.active is None:
561
+ # Keep the selector retryable if the native delete refuses access.
562
+ store.delete_under_held_lock(
563
+ expected_generation=envelope.generation,
564
+ expected_states=frozenset({"login_pending"}),
565
+ )
566
+ remove_credential_manifest_under_held_lock(path)
567
+ else:
568
+ store.replace_under_held_lock(
569
+ CredentialEnvelope.active_envelope(
570
+ generation=envelope.generation + 1, active=envelope.active
571
+ ),
572
+ expected_generation=envelope.generation,
573
+ expected_states=frozenset({"login_pending"}),
521
574
  )
522
- store.recover_login_lease_local_only()
523
575
  return _with_environment_authority(
524
576
  {
525
577
  "schema_version": _RECOVERY_SCHEMA,
526
578
  "status": "local_recovery_completed",
527
579
  "remote_revocation": "unproved",
528
- "local_state": "recovered",
580
+ "local_state": "active" if envelope.active is not None else "removed",
529
581
  "mint_outcome": "unproved",
530
582
  }
531
583
  )
@@ -652,16 +704,29 @@ def _cleanup_environment_revoked_stored_target(
652
704
  return "pending"
653
705
 
654
706
 
655
- def login_payload(*, device_name: str | None, credential_store: str | None) -> dict[str, Any]:
656
- from mostlyright.data_harness.ux.login import run_login
707
+ def login_payload(
708
+ *, device_name: str | None, credential_store: str | None, announce: bool = False
709
+ ) -> dict[str, Any]:
710
+ from mostlyright.data_harness.ux.login import announce_device_code, run_login
657
711
 
658
- return run_login(device_name=device_name, credential_store=credential_store)
712
+ return run_login(
713
+ device_name=device_name,
714
+ credential_store=credential_store,
715
+ announce=announce_device_code if announce else None,
716
+ )
659
717
 
660
718
 
661
- def rotate_payload(*, device_name: str | None, credential_store: str | None) -> dict[str, Any]:
662
- from mostlyright.data_harness.ux.login import run_login
719
+ def rotate_payload(
720
+ *, device_name: str | None, credential_store: str | None, announce: bool = False
721
+ ) -> dict[str, Any]:
722
+ from mostlyright.data_harness.ux.login import announce_device_code, run_login
663
723
 
664
- return run_login(device_name=device_name, force=True, credential_store=credential_store)
724
+ return run_login(
725
+ device_name=device_name,
726
+ force=True,
727
+ credential_store=credential_store,
728
+ announce=announce_device_code if announce else None,
729
+ )
665
730
 
666
731
 
667
732
  def tokens_payload(operation: str) -> dict[str, Any]:
@@ -22,8 +22,10 @@ from mostlyright.data_harness.ux.login import CLOUD_URL_ENV, announce_device_cod
22
22
 
23
23
  DESCRIPTION = (
24
24
  "Sign in to the Mostly Right cloud from this machine. Prints a short code and opens a browser "
25
- "to approve it there, then stores a durable device credential at ~/.mostlyright/credentials, "
26
- f"mode 0600. The raw credential is never printed; only its prefix and last four characters "
25
+ "to approve it there. New macOS/Linux logins use ~/.mostlyright/credentials.secure, an "
26
+ "unencrypted file restricted to your OS account (0600). Windows uses DPAPI encryption; "
27
+ "existing logins keep their selected store. "
28
+ f"The raw credential is never printed; only its prefix and last four characters "
27
29
  f"are. Set {CLOUD_URL_ENV} to sign in against a different cloud."
28
30
  )
29
31
 
@@ -42,7 +44,7 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
42
44
  parser.add_argument(
43
45
  "--credential-store",
44
46
  choices=("secure-file",),
45
- help="explicitly use the secure-file credential store",
47
+ help="use the private-file store (default on macOS/Linux; existing stores are preserved)",
46
48
  )
47
49
 
48
50
 
@@ -18,7 +18,7 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
18
18
  parser.add_argument(
19
19
  "--credential-store",
20
20
  choices=("secure-file",),
21
- help="explicitly use the secure-file fallback for first credential migration",
21
+ help="select private-file storage for legacy credential migration",
22
22
  )
23
23
 
24
24
 
@@ -56,8 +56,8 @@ class MacOSKeychainBackend:
56
56
  if payload is not None and len(payload) > MAX_CREDENTIAL_ENVELOPE_BYTES:
57
57
  raise ValueError("oversized item")
58
58
  return payload
59
- except Exception:
60
- raise _native_refusal("Keychain", "copy") from None
59
+ except Exception as error:
60
+ raise _keychain_refusal("read", error) from None
61
61
 
62
62
  def write(self, payload: bytes) -> None:
63
63
  if len(payload) > MAX_CREDENTIAL_ENVELOPE_BYTES:
@@ -67,16 +67,16 @@ class MacOSKeychainBackend:
67
67
  mutable = bytearray(payload)
68
68
  try:
69
69
  self._ffi.write(mutable, service=_SERVICE, account=_ACCOUNT)
70
- except Exception:
71
- raise _native_refusal("Keychain", "store") from None
70
+ except Exception as error:
71
+ raise _keychain_refusal("write", error) from None
72
72
  finally:
73
73
  _zero(mutable)
74
74
 
75
75
  def delete(self) -> None:
76
76
  try:
77
77
  self._ffi.delete(service=_SERVICE, account=_ACCOUNT)
78
- except Exception:
79
- raise _native_refusal("Keychain", "delete") from None
78
+ except Exception as error:
79
+ raise _keychain_refusal("delete", error) from None
80
80
 
81
81
 
82
82
  class DPAPIBackend:
@@ -91,7 +91,6 @@ class DPAPIBackend:
91
91
  # keeps replace/fsync/link checks identical without making secure-file a fallback.
92
92
  self._blob = SecureFileBackend(
93
93
  config_dir,
94
- explicit=True,
95
94
  filename="credentials.dpapi",
96
95
  )
97
96
  except Exception:
@@ -248,8 +247,8 @@ def native_backend(
248
247
  if executable is None:
249
248
  raise CredentialStoreError(
250
249
  "CREDENTIAL_STORE_UNAVAILABLE",
251
- "Secret Service probe failed; explicitly select "
252
- "--credential-store secure-file to use an owner-controlled file",
250
+ "Secret Service is unavailable; restore secret-tool and the desktop "
251
+ "secret service to access the existing login",
253
252
  )
254
253
  return SecretServiceBackend(executable=executable)
255
254
  except CredentialStoreError:
@@ -258,8 +257,7 @@ def native_backend(
258
257
  pass
259
258
  raise CredentialStoreError(
260
259
  "CREDENTIAL_STORE_UNAVAILABLE",
261
- "native protected storage is unavailable; explicitly select "
262
- "--credential-store secure-file to use an owner-controlled file",
260
+ "the existing login uses native protected storage that is unavailable on this system",
263
261
  )
264
262
 
265
263
 
@@ -270,6 +268,21 @@ def _native_refusal(adapter: str, outcome: str) -> CredentialStoreError:
270
268
  )
271
269
 
272
270
 
271
+ class _KeychainStatusError(OSError):
272
+ """Carry only the numeric Security.framework result, never native error text."""
273
+
274
+ def __init__(self, status: int) -> None:
275
+ self.status = status
276
+ super().__init__("Keychain operation failed")
277
+
278
+
279
+ def _keychain_refusal(operation: str, error: Exception) -> CredentialStoreError:
280
+ detail = f"Keychain {operation} failed"
281
+ if isinstance(error, _KeychainStatusError):
282
+ detail += f" (OSStatus {error.status})"
283
+ return CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", detail)
284
+
285
+
273
286
  def _zero(value: bytearray) -> None:
274
287
  for index in range(len(value)):
275
288
  value[index] = 0
@@ -491,8 +504,10 @@ class _MacOSSecurityFramework:
491
504
  status = self._security.SecItemCopyMatching(rich_query, ctypes.byref(result))
492
505
  if status == self._NOT_FOUND:
493
506
  return None
494
- if status != self._SUCCESS or not result.value:
495
- raise OSError("SecItemCopyMatching failed")
507
+ if status != self._SUCCESS:
508
+ raise _KeychainStatusError(status)
509
+ if not result.value:
510
+ raise OSError("SecItemCopyMatching returned no data")
496
511
  length = self._core.CFDataGetLength(result)
497
512
  if length < 0 or length > MAX_CREDENTIAL_ENVELOPE_BYTES:
498
513
  raise OSError("invalid Keychain item size")
@@ -525,7 +540,7 @@ class _MacOSSecurityFramework:
525
540
  if status == self._DUPLICATE:
526
541
  status = self._security.SecItemUpdate(query, attributes)
527
542
  if status != self._SUCCESS:
528
- raise OSError("Keychain write failed")
543
+ raise _KeychainStatusError(status)
529
544
  finally:
530
545
  self._core.CFRelease(attributes)
531
546
  self._core.CFRelease(data)
@@ -537,7 +552,7 @@ class _MacOSSecurityFramework:
537
552
  try:
538
553
  status = self._security.SecItemDelete(query)
539
554
  if status not in {self._SUCCESS, self._NOT_FOUND}:
540
- raise OSError("SecItemDelete failed")
555
+ raise _KeychainStatusError(status)
541
556
  finally:
542
557
  self._core.CFRelease(query)
543
558
  self._release_all(owned)
@@ -324,7 +324,7 @@ class CredentialBackend(Protocol):
324
324
 
325
325
 
326
326
  class SecureFileBackend:
327
- """Explicit, atomic 0600 fallback backend; never selected implicitly."""
327
+ """Atomic owner-only file backend, selected by policy or a saved manifest."""
328
328
 
329
329
  name = "secure-file"
330
330
 
@@ -332,14 +332,8 @@ class SecureFileBackend:
332
332
  self,
333
333
  config_dir: Path,
334
334
  *,
335
- explicit: bool,
336
335
  filename: str = SECURE_FILE_NAME,
337
336
  ) -> None:
338
- if explicit is not True:
339
- raise CredentialStoreError(
340
- "CREDENTIAL_STORE_EXPLICIT_REQUIRED",
341
- "secure-file storage requires explicit selection",
342
- )
343
337
  self.config_dir = Path(config_dir)
344
338
  if not filename or filename in {".", ".."} or Path(filename).name != filename:
345
339
  raise CredentialStoreError(
@@ -11,7 +11,8 @@ import contextlib
11
11
  import os
12
12
  import secrets
13
13
  import stat
14
- from collections.abc import Mapping
14
+ import sys
15
+ from collections.abc import Callable, Mapping
15
16
  from dataclasses import dataclass
16
17
  from pathlib import Path
17
18
  from typing import Any
@@ -175,12 +176,12 @@ def store_credentials(
175
176
  )
176
177
  control = _read_control(path)
177
178
  if control is None:
178
- backend = _backend_for(directory, selector or _native_backend_name(directory))
179
+ backend = _backend_for(directory, selector or _default_backend_name())
179
180
  current = None
180
181
  else:
181
182
  kind, value = _parse_control(control, path)
182
183
  if kind == "legacy":
183
- backend = _backend_for(directory, selector or _native_backend_name(directory))
184
+ backend = _backend_for(directory, selector or _default_backend_name())
184
185
  current = None
185
186
  else:
186
187
  backend_name = value["backend"]
@@ -232,7 +233,7 @@ def read_credentials(
232
233
  return _active_credentials(envelope)
233
234
 
234
235
  legacy = _legacy_credentials(value, path)
235
- backend = _backend_for(directory, selector or _native_backend_name(directory))
236
+ backend = _backend_for(directory, selector or _default_backend_name())
236
237
  envelope = CredentialEnvelope.active_envelope(
237
238
  generation=1,
238
239
  active=legacy._protected(),
@@ -295,6 +296,7 @@ def credential_transaction_store(
295
296
  *,
296
297
  config_dir: Path | None = None,
297
298
  credential_store: str | None = None,
299
+ announce_store: Callable[[str], None] | None = None,
298
300
  ) -> tuple[CredentialStore, Path] | None:
299
301
  """Return the selected protected store and its non-secret manifest path.
300
302
 
@@ -308,7 +310,7 @@ def credential_transaction_store(
308
310
  path = directory / CREDENTIALS_FILENAME
309
311
  selector = _selector(credential_store)
310
312
  if not os.path.lexists(path):
311
- backend = _backend_for(directory, selector or _native_backend_name(directory))
313
+ backend = _backend_for(directory, selector or _default_backend_name())
312
314
  return CredentialStore(directory, backend), path
313
315
  raw = _read_control(path)
314
316
  if raw is None:
@@ -325,6 +327,8 @@ def credential_transaction_store(
325
327
  raise LoginError("LOGIN_CREDENTIALS_UNREADABLE", "the stored credential is unreadable")
326
328
  backend_name = value["backend"]
327
329
  _require_selector_agrees(selector, backend_name)
330
+ if announce_store is not None and backend_name != "secure-file":
331
+ announce_store(backend_name)
328
332
  return CredentialStore(directory, _backend_for(directory, backend_name)), path
329
333
 
330
334
 
@@ -388,17 +392,16 @@ def _selector(value: str | None) -> str | None:
388
392
  return value
389
393
 
390
394
 
391
- def _native_backend_name(directory: Path) -> str:
392
- try:
393
- return native_backend(directory).name
394
- except CredentialStoreError as error:
395
- raise login_store_error(error) from None
395
+ def _default_backend_name() -> str:
396
+ # DPAPI is non-interactive and encrypts the Windows blob; its file ACL gate proves
397
+ # allowed writers, not absence of inherited readers, so plaintext is not the default there.
398
+ return "dpapi" if sys.platform == "win32" else "secure-file"
396
399
 
397
400
 
398
401
  def _backend_for(directory: Path, backend_name: str) -> CredentialBackend:
399
402
  if backend_name == "secure-file":
400
403
  try:
401
- return SecureFileBackend(directory, explicit=True)
404
+ return SecureFileBackend(directory)
402
405
  except CredentialStoreError as error:
403
406
  raise login_store_error(error) from None
404
407
  try:
@@ -594,10 +597,7 @@ def _install_manifest(path: Path, backend_name: str) -> None:
594
597
  def login_store_error(error: CredentialStoreError) -> LoginError:
595
598
  suffix = error.code.removeprefix("CREDENTIAL_")
596
599
  details = {
597
- "CREDENTIAL_STORE_UNAVAILABLE": (
598
- "native protected storage is unavailable; explicitly select "
599
- "--credential-store secure-file to use an owner-controlled file"
600
- ),
600
+ "CREDENTIAL_STORE_UNAVAILABLE": error.detail or "the credential store is unavailable",
601
601
  "CREDENTIAL_STORE_WRITE_FAILED": "the protected credential could not be written",
602
602
  "CREDENTIAL_STORE_READBACK_FAILED": (
603
603
  "the protected credential could not be verified after writing"
@@ -472,6 +472,18 @@ def announce_device_code(authorization: DeviceAuthorization) -> None:
472
472
  print("Waiting for approval...", file=sys.stderr, flush=True)
473
473
 
474
474
 
475
+ def announce_credential_store(backend: str) -> None:
476
+ """Explain an existing native login before the OS can request access to it."""
477
+
478
+ if backend == "keychain":
479
+ print(
480
+ "This login uses macOS Keychain. macOS may ask to access mr-data's own device "
481
+ "credential (md.mostlyright.cli); no other saved passwords are requested.",
482
+ file=sys.stderr,
483
+ flush=True,
484
+ )
485
+
486
+
475
487
  def run_login(
476
488
  *,
477
489
  cloud_url: str | None = None,
@@ -508,7 +520,9 @@ def run_login(
508
520
  refuse_existing_credentials(config_dir=config_dir)
509
521
  try:
510
522
  opened = credential_transaction_store(
511
- config_dir=config_dir, credential_store=credential_store
523
+ config_dir=config_dir,
524
+ credential_store=credential_store,
525
+ announce_store=announce_credential_store if announce is not None else None,
512
526
  )
513
527
  except LoginError:
514
528
  raise
@@ -11,7 +11,7 @@ neither affordable nor honest:
11
11
  * **Families.** Every code prefix is assigned to a family -- 224 of them, plus 18 compound
12
12
  prefixes, across 38 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 103 failures a person actually meets get their own exact sentences, and
14
+ * **Curated codes.** The 107 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:
@@ -436,7 +436,8 @@ FAMILIES: dict[str, Family] = {
436
436
  "on this machine.",
437
437
  "The message names the step that stopped: starting sign-in, waiting for browser "
438
438
  "approval, or storing the credential.",
439
- "A stopped sign-in stores nothing. Run mr-data login again to retry.",
439
+ "A stopped sign-in may retain the previous login or a pending sign-in record. "
440
+ "Follow the storage or recovery instruction in the message before retrying.",
440
441
  )
441
442
  ),
442
443
  # Catalog facts and provenance require remediation distinct from Recipe source files.
@@ -1770,6 +1771,41 @@ CODES: dict[str, Remediation] = {
1770
1771
  "mr-data login --force.",
1771
1772
  ),
1772
1773
  ),
1774
+ "LOGIN_CREDENTIAL_STORE_UNAVAILABLE": Remediation(
1775
+ lines=(
1776
+ "The selected credential store could not be accessed. The message names the "
1777
+ "backend and operation when available.",
1778
+ "For an existing macOS Keychain login, unlock your login keychain and allow access "
1779
+ "to the md.mostlyright.cli item, then retry the original command. mr-data requests "
1780
+ "its own device credential, not other saved passwords.",
1781
+ "The secure-file flag cannot switch an existing Keychain login. To move to file "
1782
+ "storage, restore access and run mr-data auth logout successfully, then mr-data login.",
1783
+ ),
1784
+ ),
1785
+ "LOGIN_CREDENTIAL_STORE_CONFLICT": Remediation(
1786
+ lines=(
1787
+ "The requested storage differs from the existing login's storage. Nothing was "
1788
+ "moved to another store.",
1789
+ "Retry without --credential-store to use the existing store. On macOS/Linux, to use "
1790
+ "private-file storage instead, first run mr-data auth logout successfully, then "
1791
+ "mr-data login.",
1792
+ ),
1793
+ ),
1794
+ "LOGIN_CREDENTIAL_LOGIN_PENDING": Remediation(
1795
+ lines=(
1796
+ "A previous device sign-in is still recorded as pending. It may still be running.",
1797
+ "Finish or stop that sign-in first. If it has stopped, run "
1798
+ "mr-data auth recover --local-only to clear its pending record before retrying. "
1799
+ "This does not prove that any remotely issued device key was revoked.",
1800
+ ),
1801
+ ),
1802
+ "LOGIN_CREDENTIAL_RECOVERY_REQUIRED": Remediation(
1803
+ lines=(
1804
+ "The stored login has an unfinished operation. Starting another sign-in would "
1805
+ "lose its recovery record.",
1806
+ "Run mr-data auth recover and follow its result before retrying sign-in.",
1807
+ ),
1808
+ ),
1773
1809
  # `mr-data` narrows a credential directory it already owns, so what is left for this code to
1774
1810
  # report is something it may not touch on its own.
1775
1811
  "LOGIN_CREDENTIAL_STORE_PERMISSIONS": Remediation(