hebbrix 2.4.0__tar.gz → 2.5.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 (28) hide show
  1. {hebbrix-2.4.0 → hebbrix-2.5.0}/CHANGELOG.md +15 -0
  2. {hebbrix-2.4.0/hebbrix.egg-info → hebbrix-2.5.0}/PKG-INFO +57 -6
  3. {hebbrix-2.4.0 → hebbrix-2.5.0}/README.md +32 -3
  4. {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/__init__.py +1 -1
  5. {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/client.py +19 -2
  6. hebbrix-2.5.0/hebbrix/exceptions.py +135 -0
  7. {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/resources.py +209 -14
  8. {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/sync_client.py +203 -17
  9. {hebbrix-2.4.0 → hebbrix-2.5.0/hebbrix.egg-info}/PKG-INFO +57 -6
  10. {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix.egg-info/SOURCES.txt +2 -0
  11. {hebbrix-2.4.0 → hebbrix-2.5.0}/pyproject.toml +5 -4
  12. hebbrix-2.5.0/tests/test_evidence_loop_resource.py +158 -0
  13. hebbrix-2.5.0/tests/test_evidence_safety_boundary.py +42 -0
  14. {hebbrix-2.4.0 → hebbrix-2.5.0}/tests/test_memories_resource.py +200 -1
  15. {hebbrix-2.4.0 → hebbrix-2.5.0}/tests/test_openapi_parity.py +0 -50
  16. hebbrix-2.5.0/tests/test_sync_client.py +305 -0
  17. hebbrix-2.4.0/hebbrix/exceptions.py +0 -76
  18. hebbrix-2.4.0/tests/test_sync_client.py +0 -130
  19. {hebbrix-2.4.0 → hebbrix-2.5.0}/LICENSE +0 -0
  20. {hebbrix-2.4.0 → hebbrix-2.5.0}/MANIFEST.in +0 -0
  21. {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/chat.py +0 -0
  22. {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/models.py +0 -0
  23. {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix.egg-info/dependency_links.txt +0 -0
  24. {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix.egg-info/requires.txt +0 -0
  25. {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix.egg-info/top_level.txt +0 -0
  26. {hebbrix-2.4.0 → hebbrix-2.5.0}/setup.cfg +0 -0
  27. {hebbrix-2.4.0 → hebbrix-2.5.0}/tests/test_advanced_resources.py +0 -0
  28. {hebbrix-2.4.0 → hebbrix-2.5.0}/tests/test_procedural_resource.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.5.0 — 2026-09-07
4
+
5
+ - Add matching synchronous and asynchronous Evidence Loop methods: owner-managed verifiers, durable episodes, actual-execution claims, protected outcome delivery and evidence assessments.
6
+ - Preserve scope, replay, incomplete-outcome and permission receipts without converting recommendations into execution authority or falling back to caller-reported outcomes.
7
+ - Reject malformed, ambiguous or unknown evidence contracts before exposing unsupported synthesis; retain valid degraded evidence with its abstention signal.
8
+ - Protected ledger methods require the connected Evidence Loop backend. Separate verifier credentials and an independent execution/outcome check remain required; these methods do not execute actions.
9
+
10
+ ## 2.4.1 — 2026-08-27
11
+
12
+ - Make every async and sync readiness deadline preserve the original durable
13
+ receipt and raise top-level `IndexingTimeoutError`, including single writes,
14
+ batches, inference jobs, and direct updates.
15
+ - Normalize memory/job/status/request/outbox/idempotency recovery metadata and
16
+ preserve transport-only `Location`, request, retry, event, and replay headers.
17
+
3
18
  ## 2.4.0 — 2026-08-27
4
19
 
5
20
  - Reconcile every exported advanced method with the canonical public OpenAPI,
@@ -1,13 +1,35 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hebbrix
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs
5
5
  Author-email: Hebbrix Team <support@hebbrix.com>
6
6
  Maintainer-email: Hebbrix Team <support@hebbrix.com>
7
- License-Expression: MIT
7
+ License: MIT License
8
+
9
+ Copyright (c) 2025 Hebbrix
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
8
29
  Project-URL: Homepage, https://hebbrix.com
9
30
  Project-URL: Documentation, https://docs.hebbrix.com
10
- Project-URL: Source, https://pypi.org/project/hebbrix/#files
31
+ Project-URL: Source, https://github.com/Hebbrix/hebbrix-python
32
+ Project-URL: Issues, https://github.com/Hebbrix/hebbrix-python/issues
11
33
  Project-URL: Support, https://www.hebbrix.com/contact
12
34
  Project-URL: API Reference, https://api.hebbrix.com/docs
13
35
  Keywords: ai,memory,agents,llm,chatbot,assistant,ml,reinforcement-learning,knowledge-graph,vector-search,rag,temporal,procedural-memory,working-memory
@@ -46,7 +68,7 @@ Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
46
68
  ## Install
47
69
 
48
70
  ```bash
49
- pip install hebbrix==2.4.0
71
+ pip install hebbrix==2.5.0
50
72
  ```
51
73
 
52
74
  Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
@@ -87,8 +109,37 @@ converging; it is not a failure and does not justify a duplicate write.
87
109
  When `wait_for_index=True`, the SDK accepts that receipt and polls the documented
88
110
  status URL. It returns only after `searchable=true`. If the caller's deadline
89
111
  expires, it raises `IndexingTimeoutError`; the exception retains the original
90
- receipt, durable memory IDs, and status URL. Reuse the same idempotency key with
91
- the same body to recover the same logical resources.
112
+ receipt plus normalized `memory_ids`, `job_id`, `status_url`, `request_id`,
113
+ `outbox_event_id`, retry timing, and idempotency replay metadata when available.
114
+ The synchronous and asynchronous single, batch, inference-job, and update
115
+ readiness paths share this behavior. The SDK never repeats the write while it
116
+ polls.
117
+
118
+ Catch the typed deadline without discarding the durable acceptance:
119
+
120
+ ```python
121
+ from hebbrix import IndexingTimeoutError
122
+
123
+ try:
124
+ created = await client.memories.create(
125
+ content="Customer prefers concise replies",
126
+ wait_for_index=True,
127
+ idempotency_key="customer-42-preference-v1",
128
+ index_timeout=5,
129
+ )
130
+ except IndexingTimeoutError as exc:
131
+ # Resume observation; do not submit an unrelated second write.
132
+ if exc.memory_ids:
133
+ created = await client.memories.wait_until_searchable(exc.memory_ids[0])
134
+ elif exc.job_id:
135
+ created = await client.memory_jobs.wait(exc.job_id)
136
+ ```
137
+
138
+ For a timed-out batch, pass `exc.receipt` to
139
+ `memories.wait_batch_until_searchable(...)`. Alternatively, replay the exact
140
+ same body with `exc.idempotency_key`; a changed body with the same key is
141
+ rejected by the API rather than creating a second logical write. For an update,
142
+ resume polling `exc.memory_ids[0]` because the relational edit already committed.
92
143
 
93
144
  For an asynchronous batch receipt:
94
145
 
@@ -5,7 +5,7 @@ Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- pip install hebbrix==2.4.0
8
+ pip install hebbrix==2.5.0
9
9
  ```
10
10
 
11
11
  Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
@@ -46,8 +46,37 @@ converging; it is not a failure and does not justify a duplicate write.
46
46
  When `wait_for_index=True`, the SDK accepts that receipt and polls the documented
47
47
  status URL. It returns only after `searchable=true`. If the caller's deadline
48
48
  expires, it raises `IndexingTimeoutError`; the exception retains the original
49
- receipt, durable memory IDs, and status URL. Reuse the same idempotency key with
50
- the same body to recover the same logical resources.
49
+ receipt plus normalized `memory_ids`, `job_id`, `status_url`, `request_id`,
50
+ `outbox_event_id`, retry timing, and idempotency replay metadata when available.
51
+ The synchronous and asynchronous single, batch, inference-job, and update
52
+ readiness paths share this behavior. The SDK never repeats the write while it
53
+ polls.
54
+
55
+ Catch the typed deadline without discarding the durable acceptance:
56
+
57
+ ```python
58
+ from hebbrix import IndexingTimeoutError
59
+
60
+ try:
61
+ created = await client.memories.create(
62
+ content="Customer prefers concise replies",
63
+ wait_for_index=True,
64
+ idempotency_key="customer-42-preference-v1",
65
+ index_timeout=5,
66
+ )
67
+ except IndexingTimeoutError as exc:
68
+ # Resume observation; do not submit an unrelated second write.
69
+ if exc.memory_ids:
70
+ created = await client.memories.wait_until_searchable(exc.memory_ids[0])
71
+ elif exc.job_id:
72
+ created = await client.memory_jobs.wait(exc.job_id)
73
+ ```
74
+
75
+ For a timed-out batch, pass `exc.receipt` to
76
+ `memories.wait_batch_until_searchable(...)`. Alternatively, replay the exact
77
+ same body with `exc.idempotency_key`; a changed body with the same key is
78
+ rejected by the API rather than creating a second logical write. For an update,
79
+ resume polling `exc.memory_ids[0]` because the relational edit already committed.
51
80
 
52
81
  For an asynchronous batch receipt:
53
82
 
@@ -5,7 +5,7 @@ production OpenAPI and ``GET /v1/users/me/capabilities``. The experimental
5
5
  World Model is intentionally absent from this release.
6
6
  """
7
7
 
8
- __version__ = "2.4.0"
8
+ __version__ = "2.5.0"
9
9
  __author__ = "Hebbrix Team"
10
10
  __license__ = "MIT"
11
11
 
@@ -90,7 +90,7 @@ class MemoryClient:
90
90
  """Get request headers."""
91
91
  headers = {
92
92
  "Content-Type": "application/json",
93
- "User-Agent": "hebbrix-python/2.4.0",
93
+ "User-Agent": "hebbrix-python/2.5.0",
94
94
  }
95
95
 
96
96
  if self.api_key:
@@ -200,7 +200,24 @@ class MemoryClient:
200
200
  if response.status_code >= 400:
201
201
  self._handle_error(response)
202
202
 
203
- return response.json() if response.text else {}
203
+ payload = response.json() if response.text else {}
204
+ if isinstance(payload, dict):
205
+ # Preserve transport-only recovery identifiers on durable 202
206
+ # receipts. The resource layer needs these values if its local
207
+ # readiness deadline expires after the write has committed.
208
+ recovery_headers = {
209
+ "request_id": response.headers.get("X-Request-ID"),
210
+ "status_url": response.headers.get("Location"),
211
+ "outbox_event_id": response.headers.get("X-Hebbrix-Index-Event"),
212
+ "retry_after": response.headers.get("Retry-After"),
213
+ }
214
+ for key, value in recovery_headers.items():
215
+ if value and not payload.get(key):
216
+ payload[key] = value
217
+ replay = response.headers.get("X-Idempotent-Replay")
218
+ if replay is not None and "idempotency_replay" not in payload:
219
+ payload["idempotency_replay"] = replay.casefold() == "true"
220
+ return payload
204
221
 
205
222
  async def get(self, path: str, **kwargs) -> Dict[str, Any]:
206
223
  """Make a GET request."""
@@ -0,0 +1,135 @@
1
+ """
2
+ Hebbrix SDK Exceptions
3
+ """
4
+
5
+
6
+ class HebbrixError(Exception):
7
+ """Base exception for Hebbrix SDK."""
8
+
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ status_code: int = None,
13
+ *,
14
+ code: str = None,
15
+ request_id: str = None,
16
+ details: dict = None,
17
+ ):
18
+ self.message = message
19
+ self.status_code = status_code
20
+ self.code = code
21
+ self.request_id = request_id
22
+ self.details = details or {}
23
+ super().__init__(self.message)
24
+
25
+
26
+ class EntitlementError(HebbrixError):
27
+ """The authenticated account lacks the plan or role for an operation."""
28
+
29
+ def __init__(self, message: str, *, status_code: int, **kwargs):
30
+ super().__init__(message, status_code=status_code, **kwargs)
31
+
32
+
33
+ class IndexingTimeoutError(TimeoutError):
34
+ """A durable write did not become searchable before the client deadline.
35
+
36
+ The write has already committed when this exception is raised. Recovery
37
+ metadata therefore lives on the exception so callers can resume polling or
38
+ safely replay the *same* request with the same idempotency key instead of
39
+ issuing an uncorrelated duplicate write.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ message: str,
45
+ receipt: dict,
46
+ *,
47
+ idempotency_key: str = None,
48
+ ):
49
+ self.receipt = dict(receipt or {})
50
+
51
+ candidates = [
52
+ *(self.receipt.get("memory_ids") or []),
53
+ self.receipt.get("memory_id"),
54
+ self.receipt.get("id"),
55
+ *(
56
+ item.get("memory_id") or item.get("id")
57
+ for item in (self.receipt.get("results") or [])
58
+ if isinstance(item, dict)
59
+ ),
60
+ ]
61
+ self.memory_ids = list(
62
+ dict.fromkeys(str(value) for value in candidates if value)
63
+ )
64
+ self.job_id = self.receipt.get("job_id")
65
+ self.status_url = self.receipt.get("status_url")
66
+ if not self.status_url and self.memory_ids:
67
+ self.status_url = f"/v1/memories/{self.memory_ids[0]}"
68
+ if not self.status_url and self.job_id:
69
+ self.status_url = f"/v1/memory-jobs/{self.job_id}"
70
+
71
+ self.request_id = self.receipt.get("request_id")
72
+ self.outbox_event_id = self.receipt.get("outbox_event_id")
73
+ self.indexing_event_id = (
74
+ self.receipt.get("indexing_event_id") or self.outbox_event_id
75
+ )
76
+ self.event_id = self.receipt.get("event_id") or self.indexing_event_id
77
+ self.idempotency_key = idempotency_key or self.receipt.get("idempotency_key")
78
+ self.idempotency_replay = self.receipt.get(
79
+ "idempotency_replay",
80
+ self.receipt.get("idempotency_replayed"),
81
+ )
82
+ self.retry_after = self.receipt.get("retry_after")
83
+ self.recovery = {
84
+ key: value
85
+ for key, value in {
86
+ "memory_ids": list(self.memory_ids),
87
+ "job_id": self.job_id,
88
+ "status_url": self.status_url,
89
+ "request_id": self.request_id,
90
+ "outbox_event_id": self.outbox_event_id,
91
+ "indexing_event_id": self.indexing_event_id,
92
+ "event_id": self.event_id,
93
+ "idempotency_key": self.idempotency_key,
94
+ "idempotency_replay": self.idempotency_replay,
95
+ "retry_after": self.retry_after,
96
+ }.items()
97
+ if value not in (None, "", [])
98
+ }
99
+ super().__init__(message)
100
+
101
+
102
+ class AuthenticationError(HebbrixError):
103
+ """Raised when authentication fails."""
104
+
105
+ def __init__(self, message: str = "Authentication failed", **kwargs):
106
+ super().__init__(message, status_code=401, **kwargs)
107
+
108
+
109
+ class ValidationError(HebbrixError):
110
+ """Raised when request validation fails."""
111
+
112
+ def __init__(self, message: str, errors: list = None, **kwargs):
113
+ self.errors = errors or []
114
+ super().__init__(message, status_code=422, **kwargs)
115
+
116
+
117
+ class NotFoundError(HebbrixError):
118
+ """Raised when a resource is not found."""
119
+
120
+ def __init__(self, message: str = "Resource not found", **kwargs):
121
+ super().__init__(message, status_code=404, **kwargs)
122
+
123
+
124
+ class RateLimitError(HebbrixError):
125
+ """Raised when rate limit is exceeded."""
126
+
127
+ def __init__(self, message: str = "Rate limit exceeded", **kwargs):
128
+ super().__init__(message, status_code=429, **kwargs)
129
+
130
+
131
+ class ServerError(HebbrixError):
132
+ """Raised when server returns 5xx error."""
133
+
134
+ def __init__(self, message: str = "Internal server error", **kwargs):
135
+ super().__init__(message, status_code=500, **kwargs)
@@ -9,6 +9,7 @@ import json
9
9
  import time
10
10
  import uuid
11
11
  from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional
12
+ from urllib.parse import quote
12
13
 
13
14
  from hebbrix.exceptions import IndexingTimeoutError
14
15
  from hebbrix.models import SearchSafetyEnvelope
@@ -97,9 +98,9 @@ def _canonical_search_envelope(
97
98
  ) -> Dict[str, Any]:
98
99
  """Validate API-owned evidence metadata and fail closed on contract drift."""
99
100
 
100
- data = dict(response or {})
101
- rows = data.get(rows_key)
102
- rows = rows if isinstance(rows, list) else []
101
+ data = dict(response) if isinstance(response, dict) else {}
102
+ raw_rows = data.get(rows_key)
103
+ rows = raw_rows if isinstance(raw_rows, list) else []
103
104
  missing = sorted(_SEARCH_SAFETY_FIELDS.difference(data))
104
105
  reason: Optional[str] = None
105
106
  confidence = data.get("query_confidence")
@@ -117,6 +118,27 @@ def _canonical_search_envelope(
117
118
  reason = "invalid_grounding_receipt"
118
119
  elif not isinstance(data.get("evidence_ids"), list):
119
120
  reason = "invalid_evidence_ids"
121
+ elif data.get("safety_contract_version") != "search-safety-v1":
122
+ reason = "unsupported_safety_contract_version"
123
+ elif not isinstance(raw_rows, list):
124
+ reason = "invalid_evidence_rows"
125
+ elif any(
126
+ not isinstance(value, str) or not value.strip()
127
+ for value in data["evidence_ids"]
128
+ ):
129
+ reason = "invalid_evidence_ids"
130
+ elif len(set(data["evidence_ids"])) != len(data["evidence_ids"]):
131
+ reason = "duplicate_evidence_ids"
132
+ elif any(
133
+ not isinstance(row, dict)
134
+ or not isinstance(row.get("memory_id", row.get("id")), str)
135
+ or not row.get("memory_id", row.get("id", "")).strip()
136
+ or ("memory_id" in row and "id" in row and row["memory_id"] != row["id"])
137
+ for row in rows
138
+ ):
139
+ reason = "invalid_evidence_row_identity"
140
+ elif not rows and data.get("no_match") is False:
141
+ reason = "no_evidence_rows"
120
142
  else:
121
143
  row_ids = {
122
144
  str(row.get("memory_id") or row.get("id"))
@@ -143,6 +165,9 @@ def _canonical_search_envelope(
143
165
  data["query_confidence"] = 0.0
144
166
  data["evidence_ids"] = []
145
167
  data["evidence_claims"] = []
168
+ if rows_key == "sources":
169
+ data["answer"] = None
170
+ data["citations"] = []
146
171
  if reason:
147
172
  data["sdk_safety_reason"] = reason
148
173
  data["grounding"] = {
@@ -457,6 +482,7 @@ class MemoriesResource(BaseResource):
457
482
  receipt,
458
483
  timeout=index_timeout,
459
484
  poll_interval=index_poll_interval,
485
+ idempotency_key=idempotency_key,
460
486
  )
461
487
  return receipt
462
488
 
@@ -505,11 +531,15 @@ class MemoriesResource(BaseResource):
505
531
  if idempotency_key:
506
532
  kwargs["headers"] = {"Idempotency-Key": idempotency_key}
507
533
  receipt = await self.client.post("/v1/memories/batch", **kwargs)
508
- if wait_for_index and not receipt.get("searchable"):
534
+ if wait_for_index and not (
535
+ receipt.get("searchable") is True
536
+ and str(receipt.get("processing_status") or "").casefold() == "completed"
537
+ ):
509
538
  receipt = await self.wait_batch_until_searchable(
510
539
  receipt,
511
540
  timeout=index_timeout,
512
541
  poll_interval=index_poll_interval,
542
+ idempotency_key=idempotency_key,
513
543
  )
514
544
  return receipt
515
545
 
@@ -519,6 +549,7 @@ class MemoriesResource(BaseResource):
519
549
  *,
520
550
  timeout: float = 60.0,
521
551
  poll_interval: float = 0.5,
552
+ idempotency_key: Optional[str] = None,
522
553
  ) -> Dict[str, Any]:
523
554
  """Poll every item in an asynchronous batch receipt to one terminal state.
524
555
 
@@ -537,11 +568,20 @@ class MemoriesResource(BaseResource):
537
568
  )
538
569
  for row in rows:
539
570
  state = str(row.get("processing_status") or "").casefold()
571
+ if state == "completed" and row.get("searchable") is not True:
572
+ raise RuntimeError(
573
+ f"memory {row.get('id')} reported completed without "
574
+ "searchable=true"
575
+ )
540
576
  if state in {"failed", "cancelled", "canceled"}:
541
577
  raise RuntimeError(
542
578
  f"memory {row.get('id')} indexing reached terminal state {state}"
543
579
  )
544
- if all(row.get("searchable") is True for row in rows):
580
+ if all(
581
+ row.get("searchable") is True
582
+ and str(row.get("processing_status") or "").casefold() == "completed"
583
+ for row in rows
584
+ ):
545
585
  return {
546
586
  **receipt,
547
587
  "processing_status": "completed",
@@ -556,10 +596,14 @@ class MemoriesResource(BaseResource):
556
596
  ],
557
597
  }
558
598
  if time.monotonic() >= deadline:
559
- raise IndexingTimeoutError(
599
+ timeout_error = TimeoutError(
560
600
  f"batch was not searchable within {timeout}s; the write is durable",
561
- receipt,
562
601
  )
602
+ raise IndexingTimeoutError(
603
+ str(timeout_error),
604
+ receipt,
605
+ idempotency_key=idempotency_key,
606
+ ) from timeout_error
563
607
  await asyncio.sleep(max(0.05, poll_interval))
564
608
 
565
609
  async def wait_until_searchable(
@@ -597,6 +641,7 @@ class MemoriesResource(BaseResource):
597
641
  *,
598
642
  timeout: float,
599
643
  poll_interval: float,
644
+ idempotency_key: Optional[str] = None,
600
645
  ) -> Dict[str, Any]:
601
646
  if (
602
647
  receipt.get("searchable") is True
@@ -619,9 +664,14 @@ class MemoriesResource(BaseResource):
619
664
  f"memory job {job_id} reached terminal state {state}"
620
665
  )
621
666
  if time.monotonic() >= deadline:
622
- raise TimeoutError(
667
+ timeout_error = TimeoutError(
623
668
  f"memory job {job_id} did not become searchable within {timeout}s"
624
669
  )
670
+ raise IndexingTimeoutError(
671
+ f"{timeout_error}; the write is durable",
672
+ receipt,
673
+ idempotency_key=idempotency_key,
674
+ ) from timeout_error
625
675
  await asyncio.sleep(max(0.05, poll_interval))
626
676
 
627
677
  candidates = [
@@ -637,11 +687,19 @@ class MemoriesResource(BaseResource):
637
687
  raise RuntimeError(
638
688
  "wait_for_index response contained neither a memory id nor a job id"
639
689
  )
640
- ready = await self.wait_until_searchable(
641
- memory_id,
642
- timeout=timeout,
643
- poll_interval=poll_interval,
644
- )
690
+ try:
691
+ ready = await self.wait_until_searchable(
692
+ memory_id,
693
+ timeout=timeout,
694
+ poll_interval=poll_interval,
695
+ )
696
+ except TimeoutError as exc:
697
+ raise IndexingTimeoutError(
698
+ f"memory {memory_id} was not searchable within {timeout}s; "
699
+ "the write is durable",
700
+ receipt,
701
+ idempotency_key=idempotency_key,
702
+ ) from exc
645
703
  receipt["searchable"] = True
646
704
  receipt["processing_status"] = "completed"
647
705
  receipt["status_url"] = ready.get("status_url") or f"/v1/memories/{memory_id}"
@@ -793,6 +851,8 @@ class MemoriesResource(BaseResource):
793
851
  importance: Optional[float] = None,
794
852
  metadata: Optional[Dict[str, Any]] = None,
795
853
  wait_for_index: Optional[bool] = None,
854
+ index_timeout: float = 60.0,
855
+ index_poll_interval: float = 0.5,
796
856
  ) -> Dict[str, Any]:
797
857
  """
798
858
  Update a memory.
@@ -816,7 +876,15 @@ class MemoriesResource(BaseResource):
816
876
  if wait_for_index is not None:
817
877
  data["wait_for_index"] = wait_for_index
818
878
 
819
- return await self.client.patch(f"/v1/memories/{memory_id}", json=data)
879
+ receipt = await self.client.patch(f"/v1/memories/{memory_id}", json=data)
880
+ if wait_for_index is True:
881
+ receipt = {**receipt, "id": receipt.get("id") or memory_id}
882
+ receipt = await self._ensure_searchable_receipt(
883
+ receipt,
884
+ timeout=index_timeout,
885
+ poll_interval=index_poll_interval,
886
+ )
887
+ return receipt
820
888
 
821
889
  async def delete(self, memory_id: str) -> None:
822
890
  """
@@ -1113,6 +1181,133 @@ class ProofLoopResource(BaseResource):
1113
1181
  body["proof_context_token"] = token
1114
1182
  return await self.client.post("/v1/learning/decisions", json=body)
1115
1183
 
1184
+ async def register_verifier(
1185
+ self,
1186
+ *,
1187
+ policy_key: str,
1188
+ api_key_id: str,
1189
+ source_system: str,
1190
+ metric_keys: List[str],
1191
+ collection_id: Optional[str] = None,
1192
+ user_id: Optional[str] = None,
1193
+ ) -> Dict[str, Any]:
1194
+ """Owner-session administration; the agent cannot register its own verifier."""
1195
+ return await self.client.post(
1196
+ "/v1/learning/verifiers",
1197
+ json={
1198
+ "policy_key": policy_key,
1199
+ "api_key_id": api_key_id,
1200
+ "source_system": source_system,
1201
+ "metric_keys": metric_keys,
1202
+ "collection_id": collection_id,
1203
+ "user_id": user_id,
1204
+ },
1205
+ )
1206
+
1207
+ async def revoke_verifier(self, verifier_id: str) -> Dict[str, Any]:
1208
+ """Revoke one source without deleting historical evidence."""
1209
+ return await self.client.post(
1210
+ f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/revoke", json={}
1211
+ )
1212
+
1213
+ async def create_episode(
1214
+ self,
1215
+ *,
1216
+ policy_key: str,
1217
+ verifier_id: str,
1218
+ idempotency_key: str,
1219
+ collection_id: Optional[str] = None,
1220
+ user_id: Optional[str] = None,
1221
+ ) -> Dict[str, Any]:
1222
+ """Create a scoped durable episode; never grants execution permission."""
1223
+ return await self.client.post(
1224
+ "/v1/learning/episodes",
1225
+ json={
1226
+ "policy_key": policy_key,
1227
+ "verifier_id": verifier_id,
1228
+ "idempotency_key": idempotency_key,
1229
+ "collection_id": collection_id,
1230
+ "user_id": user_id,
1231
+ },
1232
+ )
1233
+
1234
+ async def get_episode(self, episode_id: str, *, offset: int = 0) -> Dict[str, Any]:
1235
+ return await self.client.get(
1236
+ f"/v1/learning/episodes/{quote(episode_id, safe='')}",
1237
+ params={"offset": offset},
1238
+ )
1239
+
1240
+ async def close_episode(self, episode_id: str, *, status: str) -> Dict[str, Any]:
1241
+ return await self.client.post(
1242
+ f"/v1/learning/episodes/{quote(episode_id, safe='')}/close",
1243
+ json={"status": status},
1244
+ )
1245
+
1246
+ async def record_execution(
1247
+ self,
1248
+ decision_id: str,
1249
+ *,
1250
+ attempt_id: str,
1251
+ status: str,
1252
+ actual_action_key: str,
1253
+ arguments_digest: str,
1254
+ evidence_digest: Optional[str] = None,
1255
+ occurred_at: Optional[str] = None,
1256
+ ) -> Dict[str, Any]:
1257
+ """Record an execution claim, not an instruction to execute."""
1258
+ body = {
1259
+ "attempt_id": attempt_id,
1260
+ "status": status,
1261
+ "actual_action_key": actual_action_key,
1262
+ "arguments_digest": arguments_digest,
1263
+ }
1264
+ if evidence_digest is not None:
1265
+ body["evidence_digest"] = evidence_digest
1266
+ if occurred_at is not None:
1267
+ body["occurred_at"] = occurred_at
1268
+ return await self.client.post(
1269
+ f"/v1/learning/decisions/{quote(decision_id, safe='')}/executions",
1270
+ json=body,
1271
+ )
1272
+
1273
+ async def assessment(
1274
+ self, decision_id: str, *, evidence_offset: int = 0
1275
+ ) -> Dict[str, Any]:
1276
+ return await self.client.get(
1277
+ f"/v1/learning/decisions/{quote(decision_id, safe='')}/assessment",
1278
+ params={"evidence_offset": evidence_offset},
1279
+ )
1280
+
1281
+ async def verifier_evidence(
1282
+ self, verifier_id: str, decision_id: str
1283
+ ) -> Dict[str, Any]:
1284
+ """Call with the dedicated verifier client, never the actor's credential."""
1285
+ return await self.client.get(
1286
+ f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/decisions/{quote(decision_id, safe='')}"
1287
+ )
1288
+
1289
+ async def deliver_verified_outcomes(
1290
+ self,
1291
+ verifier_id: str,
1292
+ *,
1293
+ decision_id: str,
1294
+ source_event_id: str,
1295
+ evidence_digest: str,
1296
+ execution_digest: str,
1297
+ observations: List[Dict[str, Any]],
1298
+ ) -> Dict[str, Any]:
1299
+ """Deliver independently checked observations using the registered source key."""
1300
+ return await self.client.post(
1301
+ f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/events",
1302
+ json={
1303
+ "decision_id": decision_id,
1304
+ "source_event_id": source_event_id,
1305
+ "evidence_digest": evidence_digest,
1306
+ "execution_digest": execution_digest,
1307
+ "observations": observations,
1308
+ },
1309
+ )
1310
+
1116
1311
  async def get_decision(self, decision_id: str) -> Dict[str, Any]:
1117
1312
  return await self.client.get(f"/v1/learning/decisions/{decision_id}")
1118
1313