sediment-cli 0.1.0__tar.gz → 0.2.0__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 (73) hide show
  1. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/PKG-INFO +5 -5
  2. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/pyproject.toml +5 -5
  3. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/cli.py +41 -1
  4. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/client.py +50 -1
  5. sediment_cli-0.2.0/sediment_cli/evidence.py +195 -0
  6. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_capture_authority.py +31 -1
  7. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_db.py +21 -5
  8. sediment_cli-0.2.0/tests/test_cli_evidence.py +738 -0
  9. sediment_cli-0.2.0/tests/test_cli_evidence_integration.py +488 -0
  10. sediment_cli-0.2.0/tests/testdata/help/evidence-fetch.txt +12 -0
  11. sediment_cli-0.2.0/tests/testdata/help/evidence-inspect.txt +11 -0
  12. sediment_cli-0.2.0/tests/testdata/help/evidence-inventory.txt +10 -0
  13. sediment_cli-0.2.0/tests/testdata/help/evidence.txt +12 -0
  14. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/sediment.txt +2 -1
  15. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/.gitignore +0 -0
  16. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/LICENSE +0 -0
  17. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/__init__.py +0 -0
  18. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/attribution.py +0 -0
  19. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/delivery.py +0 -0
  20. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/local_postgres.py +0 -0
  21. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/transcript.py +0 -0
  22. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/ui.py +0 -0
  23. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/conftest.py +0 -0
  24. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli.py +0 -0
  25. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_help.py +0 -0
  26. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_libpq_unavailable.py +0 -0
  27. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_remote.py +0 -0
  28. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_ui.py +0 -0
  29. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_consumer_profile_cli.py +0 -0
  30. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_delivery_transport.py +0 -0
  31. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_demo_verb.py +0 -0
  32. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_e2e_quickstart.py +0 -0
  33. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_installed_wheel.py +0 -0
  34. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_local_postgres.py +0 -0
  35. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/commit.txt +0 -0
  36. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/db-provision.txt +0 -0
  37. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/db-status.txt +0 -0
  38. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/db-upgrade.txt +0 -0
  39. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/db.txt +0 -0
  40. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/delivery-enqueue.txt +0 -0
  41. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/delivery-replay.txt +0 -0
  42. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/delivery-status.txt +0 -0
  43. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/delivery.txt +0 -0
  44. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/demo.txt +0 -0
  45. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/derive.txt +0 -0
  46. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/doctor.txt +0 -0
  47. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export-diff-sft.txt +0 -0
  48. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export-dpo.txt +0 -0
  49. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export-recovery.txt +0 -0
  50. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export-rlvr.txt +0 -0
  51. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export-sft.txt +0 -0
  52. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export.txt +0 -0
  53. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/facts.txt +0 -0
  54. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/install.txt +0 -0
  55. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/login.txt +0 -0
  56. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/logout.txt +0 -0
  57. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/mirror-gc.txt +0 -0
  58. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/quarantine-inference-calls.txt +0 -0
  59. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/quarantine-log.txt +0 -0
  60. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/quarantine.txt +0 -0
  61. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/release.txt +0 -0
  62. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-abandonment.txt +0 -0
  63. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-attribution-share.txt +0 -0
  64. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-dataset-diagnostics.txt +0 -0
  65. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-label-confidence-inspection.txt +0 -0
  66. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-lifecycle.txt +0 -0
  67. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-merge-retention.txt +0 -0
  68. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-model.txt +0 -0
  69. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-precision.txt +0 -0
  70. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-recovery-yield.txt +0 -0
  71. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report.txt +0 -0
  72. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/server.txt +0 -0
  73. {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/uninstall.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sediment-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: The sediment command: login, install, server, facts, exports
5
5
  Project-URL: Source, https://github.com/sediment-ai/sediment
6
6
  Project-URL: Documentation, https://github.com/sediment-ai/sediment/tree/main/docs
@@ -10,7 +10,7 @@ License-Expression: AGPL-3.0-or-later
10
10
  License-File: LICENSE
11
11
  Requires-Python: >=3.12
12
12
  Requires-Dist: httpx>=0.27
13
- Requires-Dist: sediment-api==0.1.0
14
- Requires-Dist: sediment-core==0.1.0
15
- Requires-Dist: sediment-derive==0.1.0
16
- Requires-Dist: sediment-export==0.1.0
13
+ Requires-Dist: sediment-api==0.2.0
14
+ Requires-Dist: sediment-core==0.2.0
15
+ Requires-Dist: sediment-derive==0.2.0
16
+ Requires-Dist: sediment-export==0.2.0
@@ -5,7 +5,7 @@
5
5
  # Depends on sediment-api deliberately: the operator verbs are DB-local by
6
6
  # design (ADR 0001) and `server`/reports/mirror-gc forward into it.
7
7
  name = "sediment-cli"
8
- version = "0.1.0"
8
+ version = "0.2.0"
9
9
  description = "The sediment command: login, install, server, facts, exports"
10
10
  requires-python = ">=3.12"
11
11
  license = "AGPL-3.0-or-later"
@@ -14,10 +14,10 @@ license-files = ["LICENSE"]
14
14
  # so an install never mixes member versions (the release workflow asserts
15
15
  # the versions agree before publishing).
16
16
  dependencies = [
17
- "sediment-core==0.1.0",
18
- "sediment-derive==0.1.0",
19
- "sediment-export==0.1.0",
20
- "sediment-api==0.1.0",
17
+ "sediment-core==0.2.0",
18
+ "sediment-derive==0.2.0",
19
+ "sediment-export==0.2.0",
20
+ "sediment-api==0.2.0",
21
21
  "httpx>=0.27",
22
22
  ]
23
23
 
@@ -1506,6 +1506,46 @@ def build_parser() -> argparse.ArgumentParser:
1506
1506
  )
1507
1507
  p_facts.set_defaults(func=cmd_facts)
1508
1508
 
1509
+ from .evidence import command as evidence_command
1510
+
1511
+ p_evidence = sub.add_parser(
1512
+ "evidence", help="read selected Session evidence with operator authority"
1513
+ )
1514
+ evidence_sub = p_evidence.add_subparsers(
1515
+ dest="evidence_operation",
1516
+ required=True,
1517
+ title="operations",
1518
+ metavar="<operation>",
1519
+ prog=p_evidence.prog,
1520
+ )
1521
+ for operation, help_text in (
1522
+ ("inventory", "print a complete bounded Session inventory as JSON"),
1523
+ ("inspect", "print one Inference call's part manifest as JSON"),
1524
+ ("fetch", "write exact selected parts to a private local packet"),
1525
+ ):
1526
+ command = evidence_sub.add_parser(operation, help=help_text)
1527
+ command.add_argument("session_id", metavar="SESSION", help="source Session ID")
1528
+ if operation == "inspect":
1529
+ command.add_argument(
1530
+ "inference_call_id",
1531
+ metavar="INFERENCE_CALL",
1532
+ help="Inference call Fact ID",
1533
+ )
1534
+ elif operation == "fetch":
1535
+ command.add_argument(
1536
+ "--references",
1537
+ required=True,
1538
+ metavar="PATH",
1539
+ help="version 1 selection JSON (64 KiB; 1–32 distinct references)",
1540
+ )
1541
+ command.add_argument(
1542
+ "--output",
1543
+ required=True,
1544
+ metavar="PATH",
1545
+ help="packet destination (0600; must not exist)",
1546
+ )
1547
+ command.set_defaults(func=evidence_command)
1548
+
1509
1549
  p_demo = sub.add_parser(
1510
1550
  "demo", help="plant one synthetic session so facts is non-zero"
1511
1551
  )
@@ -1795,7 +1835,7 @@ def build_parser() -> argparse.ArgumentParser:
1795
1835
 
1796
1836
  # Remote verbs speak HTTP through the client seam; they never open the fact
1797
1837
  # store and never construct Settings.
1798
- _REMOTE_VERBS = {"login", "logout", "commit", "facts", "demo"}
1838
+ _REMOTE_VERBS = {"login", "logout", "commit", "facts", "demo", "evidence"}
1799
1839
 
1800
1840
  # Dispatched pre-argparse to the stdlib-only attribution module.
1801
1841
  # install/uninstall/doctor get help stubs; the hook-plumbing verbs are
@@ -21,6 +21,7 @@ from typing import Any
21
21
  from urllib.parse import urlsplit
22
22
 
23
23
  import httpx
24
+ from sediment_core import EVIDENCE_REQUEST_BYTES_LIMIT, EVIDENCE_RESPONSE_BYTES_LIMIT
24
25
 
25
26
  from . import __version__, ui
26
27
 
@@ -241,7 +242,7 @@ def probe_me(base_url: str, token: str) -> dict[str, Any]:
241
242
  identity = resp.json()
242
243
  if (
243
244
  not isinstance(identity, dict)
244
- or identity.get("authority") not in {"operator", "ingest"}
245
+ or identity.get("authority") not in {"operator", "ingest", "retrieval"}
245
246
  or not isinstance(identity.get("client_id"), str)
246
247
  or not identity["client_id"]
247
248
  or not isinstance(identity.get("org_id"), str)
@@ -290,6 +291,54 @@ def post_json(path: str, body: Any) -> Any:
290
291
  return resp.json()
291
292
 
292
293
 
294
+ def read_evidence(
295
+ path: str, *, params: dict[str, str] | None = None, body: bytes | None = None
296
+ ) -> bytes:
297
+ """Read one complete bounded evidence response with operator credentials."""
298
+ if body is not None and len(body) > EVIDENCE_REQUEST_BYTES_LIMIT:
299
+ raise ClientError("evidence request exceeds the 64 KiB limit")
300
+ base_url, token = _resolve()
301
+ try:
302
+ with (
303
+ _http() as http,
304
+ http.stream(
305
+ "GET" if body is None else "POST",
306
+ _url(base_url, path),
307
+ params=params,
308
+ content=body,
309
+ headers={
310
+ "Authorization": f"Bearer {token}",
311
+ "Content-Type": "application/json",
312
+ "Accept-Encoding": "identity",
313
+ },
314
+ timeout=httpx.Timeout(_TIMEOUT_SECONDS, read=40.0),
315
+ ) as response,
316
+ ):
317
+ if response.status_code == 401:
318
+ raise ClientError("not logged in / token rejected — run sediment login")
319
+ if response.status_code == 403:
320
+ raise ClientError(
321
+ "operator authority required — run sediment login with an operator token"
322
+ )
323
+ if response.status_code != 200:
324
+ raise ClientError(f"evidence server error ({response.status_code})")
325
+ if (
326
+ response.headers.get("Content-Encoding", "identity").lower()
327
+ != "identity"
328
+ ):
329
+ raise ClientError("evidence response must use identity encoding")
330
+ content = bytearray()
331
+ for chunk in response.iter_bytes():
332
+ if len(content) + len(chunk) > EVIDENCE_RESPONSE_BYTES_LIMIT:
333
+ raise ClientError("evidence response exceeds the 1 MiB limit")
334
+ content.extend(chunk)
335
+ return bytes(content)
336
+ except httpx.HTTPError:
337
+ raise ClientError(
338
+ "evidence request failed; check the server connection"
339
+ ) from None
340
+
341
+
293
342
  def current_url() -> str:
294
343
  """The resolved server URL, for a verb that has to reason about *which*
295
344
  server it is about to write to."""
@@ -0,0 +1,195 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Operator-selected evidence reads and private packet publication."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import math
9
+ import os
10
+ import tempfile
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ from pydantic import TypeAdapter
15
+ from sediment_core import (
16
+ EVIDENCE_INVENTORY_LIMIT,
17
+ EVIDENCE_REQUEST_BYTES_LIMIT,
18
+ EvidenceInventory,
19
+ EvidenceManifest,
20
+ EvidenceRead,
21
+ EvidenceReference,
22
+ EvidenceSchemaVersion,
23
+ NonEmptyId,
24
+ validate_evidence_references,
25
+ )
26
+
27
+ from .client import ClientError, read_evidence
28
+
29
+
30
+ def _object(pairs):
31
+ result = {}
32
+ for key, value in pairs:
33
+ if key in result:
34
+ raise ValueError("duplicate JSON key")
35
+ result[key] = value
36
+ return result
37
+
38
+
39
+ def _finite_float(value):
40
+ result = float(value)
41
+ if not math.isfinite(result):
42
+ raise ValueError("non-finite JSON number")
43
+ return result
44
+
45
+
46
+ def _reject_constant(value):
47
+ raise ValueError("non-finite JSON number")
48
+
49
+
50
+ def _decode(content: bytes):
51
+ return json.loads(
52
+ content.decode("utf-8"),
53
+ object_pairs_hook=_object,
54
+ parse_float=_finite_float,
55
+ parse_constant=_reject_constant,
56
+ )
57
+
58
+
59
+ def _selection(path: Path) -> tuple[EvidenceReference, ...]:
60
+ with path.open("rb") as source:
61
+ content = source.read(EVIDENCE_REQUEST_BYTES_LIMIT + 1)
62
+ if len(content) > EVIDENCE_REQUEST_BYTES_LIMIT:
63
+ raise ClientError("evidence selection exceeds the 64 KiB limit")
64
+ value = _decode(content)
65
+ if not isinstance(value, dict) or set(value) != {"schema_version", "references"}:
66
+ raise ValueError("invalid selection envelope")
67
+ TypeAdapter(EvidenceSchemaVersion).validate_python(value["schema_version"])
68
+ references = TypeAdapter(tuple[EvidenceReference, ...]).validate_python(
69
+ value["references"]
70
+ )
71
+ return validate_evidence_references(references)
72
+
73
+
74
+ def _unchanged(source, canonical) -> bool:
75
+ """Reject default-filled or normalized wire data without another part schema."""
76
+ if isinstance(canonical, datetime):
77
+ return isinstance(source, str) and datetime.fromisoformat(source) == canonical
78
+ if isinstance(canonical, dict):
79
+ return (
80
+ isinstance(source, dict)
81
+ and source.keys() == canonical.keys()
82
+ and all(_unchanged(source[key], value) for key, value in canonical.items())
83
+ )
84
+ if isinstance(canonical, (list, tuple)):
85
+ return (
86
+ isinstance(source, list)
87
+ and len(source) == len(canonical)
88
+ and all(_unchanged(a, b) for a, b in zip(source, canonical, strict=True))
89
+ )
90
+ return type(source) is type(canonical) and source == canonical
91
+
92
+
93
+ def _response(content: bytes, shape):
94
+ adapter = TypeAdapter(shape)
95
+ source = _decode(content)
96
+ result = adapter.validate_python(source)
97
+ if not _unchanged(source, adapter.dump_python(result)):
98
+ raise ValueError("noncanonical evidence response")
99
+ return result
100
+
101
+
102
+ def _inventory(result: EvidenceInventory) -> None:
103
+ calls = result.calls
104
+ if (
105
+ result.visible_inference_calls != len(calls)
106
+ or len(calls) > EVIDENCE_INVENTORY_LIMIT
107
+ or len({call.inference_call_id for call in calls}) != len(calls)
108
+ or (not result.found and (calls or result.quarantined_inference_calls))
109
+ or tuple(sorted(calls, key=lambda c: (c.observed_at, c.inference_call_id)))
110
+ != calls
111
+ ):
112
+ raise ValueError("inconsistent evidence inventory")
113
+
114
+
115
+ def _manifest(result: EvidenceManifest, inference_call_id: NonEmptyId) -> None:
116
+ if result.call.inference_call_id != inference_call_id:
117
+ raise ValueError("Inference call mismatch")
118
+ indices = {"input": 0, "output": 0}
119
+ for message in result.messages:
120
+ if message.message_index != indices[message.side] or (
121
+ message.side == "input" and indices["output"]
122
+ ):
123
+ raise ValueError("inconsistent message order")
124
+ indices[message.side] += 1
125
+ for index, part in enumerate(message.parts):
126
+ if part.reference != EvidenceReference(
127
+ inference_call_id, message.side, message.message_index, index
128
+ ):
129
+ raise ValueError("inconsistent part reference")
130
+
131
+
132
+ def _publish(destination: Path, content: bytes) -> None:
133
+ """Hard-link a complete private sibling file without replacing a destination."""
134
+ fd, name = tempfile.mkstemp(prefix=".sediment-evidence-", dir=destination.parent)
135
+ temporary = Path(name)
136
+ try:
137
+ with os.fdopen(fd, "wb") as output:
138
+ os.fchmod(output.fileno(), 0o600)
139
+ output.write(content)
140
+ output.flush()
141
+ os.fsync(output.fileno())
142
+ os.link(temporary, destination)
143
+ finally:
144
+ temporary.unlink()
145
+
146
+
147
+ def command(args: argparse.Namespace) -> int:
148
+ """Run a remote evidence operation without opening the FactStore."""
149
+ try:
150
+ session_id = TypeAdapter(NonEmptyId).validate_python(args.session_id)
151
+ params = {"session_id": session_id}
152
+ if args.evidence_operation == "inventory":
153
+ content = read_evidence("/query/evidence", params=params)
154
+ result = _response(content, EvidenceInventory)
155
+ _inventory(result)
156
+ elif args.evidence_operation == "inspect":
157
+ params["inference_call_id"] = TypeAdapter(NonEmptyId).validate_python(
158
+ args.inference_call_id
159
+ )
160
+ content = read_evidence("/query/evidence/manifest", params=params)
161
+ result = _response(content, EvidenceManifest)
162
+ _manifest(result, params["inference_call_id"])
163
+ else:
164
+ destination = Path(args.output)
165
+ if os.path.lexists(destination):
166
+ raise ClientError("evidence output already exists")
167
+ references = _selection(Path(args.references))
168
+ body = json.dumps(
169
+ {
170
+ "schema_version": 1,
171
+ "session_id": session_id,
172
+ "references": TypeAdapter(
173
+ tuple[EvidenceReference, ...]
174
+ ).dump_python(references),
175
+ },
176
+ ensure_ascii=True,
177
+ allow_nan=False,
178
+ separators=(",", ":"),
179
+ ).encode("ascii")
180
+ content = read_evidence("/query/evidence/read", body=body)
181
+ result = _response(content, EvidenceRead)
182
+ if tuple(item.reference for item in result.items) != references:
183
+ raise ValueError("incomplete selection")
184
+ if result.session_id != session_id:
185
+ raise ValueError("Session mismatch")
186
+ if args.evidence_operation == "fetch":
187
+ _publish(destination, content)
188
+ print(f"{destination}: {len(result.items)} items")
189
+ else:
190
+ print(content.decode("ascii"))
191
+ return 0
192
+ except ClientError:
193
+ raise
194
+ except (OSError, ValueError, TypeError, RecursionError):
195
+ raise ClientError("evidence input, response, or output is invalid") from None
@@ -132,7 +132,7 @@ def test_install_uses_only_proven_ingest_in_shell_fish_and_codex(tmp_path, monke
132
132
  assert path.stat().st_mode & 0o777 == 0o600
133
133
 
134
134
 
135
- @pytest.mark.parametrize("authority", ["ingest", "operator", None])
135
+ @pytest.mark.parametrize("authority", ["ingest", "operator", "retrieval", None])
136
136
  def test_explicit_capture_override_requires_live_ingest_authority(
137
137
  tmp_path, monkeypatch, authority
138
138
  ):
@@ -281,3 +281,33 @@ def test_doctor_rejects_unreadable_identity_without_reproducing_response(
281
281
  attribution._doctor_server(findings)
282
282
  assert findings[0][0] == attribution.DOCTOR_FAIL
283
283
  assert "private-response" not in str(findings)
284
+
285
+
286
+ @pytest.mark.parametrize("capture", [False, True])
287
+ @pytest.mark.parametrize("plural", [False, True])
288
+ def test_retrieval_credential_cannot_enroll_or_replace_operator_login(
289
+ app_transport, monkeypatch, capsys, capture, plural
290
+ ):
291
+ from pydantic import SecretStr
292
+ from sediment_api.config import settings
293
+
294
+ token = "retrieval-test-token-long-enough"
295
+ monkeypatch.setattr(settings, "retrieval_token", SecretStr(token))
296
+ monkeypatch.setattr(
297
+ settings, "retrieval_session_id", None if plural else "source-session"
298
+ )
299
+ monkeypatch.setattr(
300
+ settings, "retrieval_session_ids", ("source-session",) if plural else None
301
+ )
302
+ original = {
303
+ "current": "https://testserver",
304
+ "servers": {"https://testserver": {"token": OPERATOR}},
305
+ }
306
+ api_client.write_config(original)
307
+ monkeypatch.setattr(sys, "stdin", io.StringIO(token + "\n"))
308
+ args = ["login", "https://testserver", "--with-token"]
309
+ if capture:
310
+ args.append("--capture")
311
+ assert cli.main(args) == 1
312
+ assert "authority required" in capsys.readouterr().err
313
+ assert api_client.read_config() == original
@@ -3,11 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from datetime import UTC, datetime
6
+ import json
6
7
  from pathlib import Path
7
8
  from uuid import uuid4
8
9
 
9
10
  from psycopg import sql
10
- from sqlalchemy import create_engine, text
11
+ from sqlalchemy import MetaData, Table, create_engine, text
11
12
  from sqlalchemy.engine import make_url
12
13
 
13
14
  import pytest
@@ -255,7 +256,7 @@ def test_db_upgrade_permission_failure_exits_nonzero_without_credentials(
255
256
 
256
257
 
257
258
  def _behind_database_url(postgres_database_factory) -> str:
258
- """Create a database migrated to 0008 (one revision below head).
259
+ """Create a database migrated to 0008 (before descriptive-text encoding).
259
260
 
260
261
  The CLI one-shot quarantine verbs write ``fact_quarantine.reason`` through
261
262
  ``_SerializedText``; against a pre-0009 database the 0009 migration would
@@ -377,9 +378,13 @@ def test_preupgrade_facts_preserve_real_counts_and_show_missing_tables(
377
378
  engine = create_engine(database_url)
378
379
  try:
379
380
  store = FactStore(engine)
380
- for fact_id in ("visible", "hidden"):
381
- store.store_inference_call(
382
- InferenceCall(
381
+ # Seed the historical physical shape, without the current alias writer.
382
+ with engine.begin() as connection:
383
+ legacy = MetaData()
384
+ calls = Table("inference_calls", legacy, autoload_with=connection)
385
+ sessions = Table("sessions", legacy, autoload_with=connection)
386
+ for fact_id in ("visible", "hidden"):
387
+ call = InferenceCall(
383
388
  inference_call_id=fact_id,
384
389
  org_id="testorg",
385
390
  session_id="old-session",
@@ -388,6 +393,17 @@ def test_preupgrade_facts_preserve_real_counts_and_show_missing_tables(
388
393
  output_messages=[],
389
394
  observed_at=datetime(2026, 9, 1, tzinfo=UTC),
390
395
  )
396
+ values = call.model_dump(mode="python")
397
+ for name in ("input_messages", "output_messages", "raw"):
398
+ values[name] = json.dumps(values[name], ensure_ascii=True)
399
+ connection.execute(calls.insert().values(**values))
400
+ connection.execute(
401
+ sessions.insert().values(
402
+ org_id=call.org_id,
403
+ session_id=call.session_id,
404
+ first_observed_at=call.observed_at,
405
+ last_observed_at=call.observed_at,
406
+ )
391
407
  )
392
408
  # Seed the pre-0009 representation directly, as the prior writer did.
393
409
  with engine.begin() as connection: