hebbrix 2.4.1__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 (26) hide show
  1. {hebbrix-2.4.1 → hebbrix-2.5.0}/CHANGELOG.md +7 -0
  2. {hebbrix-2.4.1/hebbrix.egg-info → hebbrix-2.5.0}/PKG-INFO +24 -3
  3. {hebbrix-2.4.1 → hebbrix-2.5.0}/README.md +1 -1
  4. {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/__init__.py +1 -1
  5. {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/client.py +1 -1
  6. {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/resources.py +155 -3
  7. {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/sync_client.py +127 -1
  8. {hebbrix-2.4.1 → hebbrix-2.5.0/hebbrix.egg-info}/PKG-INFO +24 -3
  9. {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix.egg-info/SOURCES.txt +2 -0
  10. {hebbrix-2.4.1 → hebbrix-2.5.0}/pyproject.toml +3 -3
  11. hebbrix-2.5.0/tests/test_evidence_loop_resource.py +158 -0
  12. hebbrix-2.5.0/tests/test_evidence_safety_boundary.py +42 -0
  13. {hebbrix-2.4.1 → hebbrix-2.5.0}/LICENSE +0 -0
  14. {hebbrix-2.4.1 → hebbrix-2.5.0}/MANIFEST.in +0 -0
  15. {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/chat.py +0 -0
  16. {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/exceptions.py +0 -0
  17. {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/models.py +0 -0
  18. {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix.egg-info/dependency_links.txt +0 -0
  19. {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix.egg-info/requires.txt +0 -0
  20. {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix.egg-info/top_level.txt +0 -0
  21. {hebbrix-2.4.1 → hebbrix-2.5.0}/setup.cfg +0 -0
  22. {hebbrix-2.4.1 → hebbrix-2.5.0}/tests/test_advanced_resources.py +0 -0
  23. {hebbrix-2.4.1 → hebbrix-2.5.0}/tests/test_memories_resource.py +0 -0
  24. {hebbrix-2.4.1 → hebbrix-2.5.0}/tests/test_openapi_parity.py +0 -0
  25. {hebbrix-2.4.1 → hebbrix-2.5.0}/tests/test_procedural_resource.py +0 -0
  26. {hebbrix-2.4.1 → hebbrix-2.5.0}/tests/test_sync_client.py +0 -0
@@ -1,5 +1,12 @@
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
+
3
10
  ## 2.4.1 — 2026-08-27
4
11
 
5
12
  - Make every async and sync readiness deadline preserve the original durable
@@ -1,10 +1,31 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hebbrix
3
- Version: 2.4.1
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
31
  Project-URL: Source, https://github.com/Hebbrix/hebbrix-python
@@ -47,7 +68,7 @@ Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
47
68
  ## Install
48
69
 
49
70
  ```bash
50
- pip install hebbrix==2.4.1
71
+ pip install hebbrix==2.5.0
51
72
  ```
52
73
 
53
74
  Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
@@ -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.1
8
+ pip install hebbrix==2.5.0
9
9
  ```
10
10
 
11
11
  Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
@@ -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.1"
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.1",
93
+ "User-Agent": "hebbrix-python/2.5.0",
94
94
  }
95
95
 
96
96
  if self.api_key:
@@ -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"] = {
@@ -1156,6 +1181,133 @@ class ProofLoopResource(BaseResource):
1156
1181
  body["proof_context_token"] = token
1157
1182
  return await self.client.post("/v1/learning/decisions", json=body)
1158
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
+
1159
1311
  async def get_decision(self, decision_id: str) -> Dict[str, Any]:
1160
1312
  return await self.client.get(f"/v1/learning/decisions/{decision_id}")
1161
1313
 
@@ -4,6 +4,7 @@ import json
4
4
  import os
5
5
  import time
6
6
  from typing import Any, Dict, List, Optional
7
+ from urllib.parse import quote
7
8
 
8
9
  import httpx
9
10
  from hebbrix.exceptions import (
@@ -660,6 +661,131 @@ class SyncProofLoopResource:
660
661
  body["proof_context_token"] = token
661
662
  return self.client.post("/v1/learning/decisions", json=body)
662
663
 
664
+ def register_verifier(
665
+ self,
666
+ *,
667
+ policy_key: str,
668
+ api_key_id: str,
669
+ source_system: str,
670
+ metric_keys: List[str],
671
+ collection_id: Optional[str] = None,
672
+ user_id: Optional[str] = None,
673
+ ) -> Dict[str, Any]:
674
+ """Owner-session administration; the agent cannot register its own verifier."""
675
+ return self.client.post(
676
+ "/v1/learning/verifiers",
677
+ json={
678
+ "policy_key": policy_key,
679
+ "api_key_id": api_key_id,
680
+ "source_system": source_system,
681
+ "metric_keys": metric_keys,
682
+ "collection_id": collection_id,
683
+ "user_id": user_id,
684
+ },
685
+ )
686
+
687
+ def revoke_verifier(self, verifier_id: str) -> Dict[str, Any]:
688
+ """Revoke one source without deleting historical evidence."""
689
+ return self.client.post(
690
+ f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/revoke", json={}
691
+ )
692
+
693
+ def create_episode(
694
+ self,
695
+ *,
696
+ policy_key: str,
697
+ verifier_id: str,
698
+ idempotency_key: str,
699
+ collection_id: Optional[str] = None,
700
+ user_id: Optional[str] = None,
701
+ ) -> Dict[str, Any]:
702
+ """Create a scoped durable episode; never grants execution permission."""
703
+ return self.client.post(
704
+ "/v1/learning/episodes",
705
+ json={
706
+ "policy_key": policy_key,
707
+ "verifier_id": verifier_id,
708
+ "idempotency_key": idempotency_key,
709
+ "collection_id": collection_id,
710
+ "user_id": user_id,
711
+ },
712
+ )
713
+
714
+ def get_episode(self, episode_id: str, *, offset: int = 0) -> Dict[str, Any]:
715
+ return self.client.get(
716
+ f"/v1/learning/episodes/{quote(episode_id, safe='')}",
717
+ params={"offset": offset},
718
+ )
719
+
720
+ def close_episode(self, episode_id: str, *, status: str) -> Dict[str, Any]:
721
+ return self.client.post(
722
+ f"/v1/learning/episodes/{quote(episode_id, safe='')}/close",
723
+ json={"status": status},
724
+ )
725
+
726
+ def record_execution(
727
+ self,
728
+ decision_id: str,
729
+ *,
730
+ attempt_id: str,
731
+ status: str,
732
+ actual_action_key: str,
733
+ arguments_digest: str,
734
+ evidence_digest: Optional[str] = None,
735
+ occurred_at: Optional[str] = None,
736
+ ) -> Dict[str, Any]:
737
+ """Record an execution claim, not an instruction to execute."""
738
+ body = {
739
+ "attempt_id": attempt_id,
740
+ "status": status,
741
+ "actual_action_key": actual_action_key,
742
+ "arguments_digest": arguments_digest,
743
+ }
744
+ if evidence_digest is not None:
745
+ body["evidence_digest"] = evidence_digest
746
+ if occurred_at is not None:
747
+ body["occurred_at"] = occurred_at
748
+ return self.client.post(
749
+ f"/v1/learning/decisions/{quote(decision_id, safe='')}/executions",
750
+ json=body,
751
+ )
752
+
753
+ def assessment(
754
+ self, decision_id: str, *, evidence_offset: int = 0
755
+ ) -> Dict[str, Any]:
756
+ return self.client.get(
757
+ f"/v1/learning/decisions/{quote(decision_id, safe='')}/assessment",
758
+ params={"evidence_offset": evidence_offset},
759
+ )
760
+
761
+ def verifier_evidence(self, verifier_id: str, decision_id: str) -> Dict[str, Any]:
762
+ """Call with the dedicated verifier client, never the actor's credential."""
763
+ return self.client.get(
764
+ f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/decisions/{quote(decision_id, safe='')}"
765
+ )
766
+
767
+ def deliver_verified_outcomes(
768
+ self,
769
+ verifier_id: str,
770
+ *,
771
+ decision_id: str,
772
+ source_event_id: str,
773
+ evidence_digest: str,
774
+ execution_digest: str,
775
+ observations: List[Dict[str, Any]],
776
+ ) -> Dict[str, Any]:
777
+ """Deliver independently checked observations using the registered source key."""
778
+ return self.client.post(
779
+ f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/events",
780
+ json={
781
+ "decision_id": decision_id,
782
+ "source_event_id": source_event_id,
783
+ "evidence_digest": evidence_digest,
784
+ "execution_digest": execution_digest,
785
+ "observations": observations,
786
+ },
787
+ )
788
+
663
789
  def get_decision(self, decision_id: str) -> Dict[str, Any]:
664
790
  return self.client.get(f"/v1/learning/decisions/{decision_id}")
665
791
 
@@ -789,7 +915,7 @@ class SyncMemoryClient:
789
915
  self.source = source or os.getenv("HEBBRIX_SOURCE")
790
916
  headers = {
791
917
  "Content-Type": "application/json",
792
- "User-Agent": "hebbrix-python/2.4.1",
918
+ "User-Agent": "hebbrix-python/2.5.0",
793
919
  }
794
920
  if api_key:
795
921
  headers["Authorization"] = f"Bearer {api_key}"
@@ -1,10 +1,31 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hebbrix
3
- Version: 2.4.1
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
31
  Project-URL: Source, https://github.com/Hebbrix/hebbrix-python
@@ -47,7 +68,7 @@ Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
47
68
  ## Install
48
69
 
49
70
  ```bash
50
- pip install hebbrix==2.4.1
71
+ pip install hebbrix==2.5.0
51
72
  ```
52
73
 
53
74
  Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
@@ -16,6 +16,8 @@ hebbrix.egg-info/dependency_links.txt
16
16
  hebbrix.egg-info/requires.txt
17
17
  hebbrix.egg-info/top_level.txt
18
18
  tests/test_advanced_resources.py
19
+ tests/test_evidence_loop_resource.py
20
+ tests/test_evidence_safety_boundary.py
19
21
  tests/test_memories_resource.py
20
22
  tests/test_openapi_parity.py
21
23
  tests/test_procedural_resource.py
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hebbrix"
7
- version = "2.4.1"
7
+ version = "2.5.0"
8
8
  description = "Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
11
- license = "MIT"
12
- license-files = ["LICENSE"]
11
+ # Keep source installation compatible with Python 3.8's setuptools release.
12
+ license = {file = "LICENSE"}
13
13
  authors = [
14
14
  {name = "Hebbrix Team", email = "support@hebbrix.com"}
15
15
  ]
@@ -0,0 +1,158 @@
1
+ """Sync/async wire parity for the protected evidence lifecycle."""
2
+
3
+ import inspect
4
+ from unittest.mock import AsyncMock, Mock
5
+
6
+ import pytest
7
+
8
+ from hebbrix.resources import ProofLoopResource
9
+ from hebbrix.sync_client import SyncProofLoopResource
10
+
11
+
12
+ SCOPE = {
13
+ "policy_key": "workflow.choice",
14
+ "collection_id": "collection",
15
+ "user_id": "end-user",
16
+ }
17
+ DELIVERY = {
18
+ "decision_id": "decision",
19
+ "source_event_id": "source-event",
20
+ "evidence_digest": "a" * 64,
21
+ "execution_digest": "b" * 64,
22
+ "observations": [
23
+ {
24
+ "metric_key": "success",
25
+ "value": 1,
26
+ "is_final": False,
27
+ "observed_at": "2026-09-07T12:00:00Z",
28
+ }
29
+ ],
30
+ }
31
+ CLAIM = {
32
+ "attempt_id": "attempt",
33
+ "status": "started",
34
+ "actual_action_key": "baseline",
35
+ "arguments_digest": "c" * 64,
36
+ }
37
+
38
+ CASES = [
39
+ (
40
+ "register_verifier",
41
+ (),
42
+ {
43
+ **SCOPE,
44
+ "api_key_id": "key",
45
+ "source_system": "verifier",
46
+ "metric_keys": ["success"],
47
+ },
48
+ "post",
49
+ "/v1/learning/verifiers",
50
+ ),
51
+ (
52
+ "create_episode",
53
+ (),
54
+ {**SCOPE, "verifier_id": "verifier", "idempotency_key": "episode-key"},
55
+ "post",
56
+ "/v1/learning/episodes",
57
+ ),
58
+ (
59
+ "revoke_verifier",
60
+ ("verifier",),
61
+ {},
62
+ "post",
63
+ "/v1/learning/verifiers/verifier/revoke",
64
+ ),
65
+ (
66
+ "get_episode",
67
+ ("episode",),
68
+ {"offset": 100},
69
+ "get",
70
+ "/v1/learning/episodes/episode",
71
+ ),
72
+ (
73
+ "close_episode",
74
+ ("episode",),
75
+ {"status": "interrupted"},
76
+ "post",
77
+ "/v1/learning/episodes/episode/close",
78
+ ),
79
+ (
80
+ "record_execution",
81
+ ("decision",),
82
+ CLAIM,
83
+ "post",
84
+ "/v1/learning/decisions/decision/executions",
85
+ ),
86
+ (
87
+ "assessment",
88
+ ("decision",),
89
+ {"evidence_offset": 100},
90
+ "get",
91
+ "/v1/learning/decisions/decision/assessment",
92
+ ),
93
+ (
94
+ "verifier_evidence",
95
+ ("verifier", "decision"),
96
+ {},
97
+ "get",
98
+ "/v1/learning/verifiers/verifier/decisions/decision",
99
+ ),
100
+ (
101
+ "deliver_verified_outcomes",
102
+ ("verifier",),
103
+ DELIVERY,
104
+ "post",
105
+ "/v1/learning/verifiers/verifier/events",
106
+ ),
107
+ ]
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ @pytest.mark.parametrize("resource_class", [ProofLoopResource, SyncProofLoopResource])
112
+ @pytest.mark.parametrize("name,args,kwargs,method,path", CASES)
113
+ async def test_protected_wire_contract_preserves_server_receipt(
114
+ resource_class, name, args, kwargs, method, path
115
+ ):
116
+ receipt = {
117
+ "authorization_granted": False,
118
+ "status": "review_required",
119
+ "replayed": True,
120
+ }
121
+ transport = Mock()
122
+ call = (
123
+ AsyncMock(return_value=receipt)
124
+ if resource_class is ProofLoopResource
125
+ else Mock(return_value=receipt)
126
+ )
127
+ setattr(transport, method, call)
128
+ result = getattr(resource_class(transport), name)(*args, **kwargs)
129
+ if inspect.isawaitable(result):
130
+ result = await result
131
+ assert result is receipt
132
+ expected = (
133
+ {"json" if method == "post" else "params": kwargs}
134
+ if kwargs or method == "post"
135
+ else {}
136
+ )
137
+ call.assert_called_once_with(path, **expected)
138
+
139
+
140
+ @pytest.mark.parametrize("resource_class", [ProofLoopResource, SyncProofLoopResource])
141
+ @pytest.mark.asyncio
142
+ async def test_source_delivery_does_not_fall_back_to_unprotected_endpoint(
143
+ resource_class,
144
+ ):
145
+ transport = Mock()
146
+ error = RuntimeError("verifier credential rejected")
147
+ transport.post = (
148
+ AsyncMock(side_effect=error)
149
+ if resource_class is ProofLoopResource
150
+ else Mock(side_effect=error)
151
+ )
152
+ with pytest.raises(RuntimeError, match="verifier credential rejected"):
153
+ result = resource_class(transport).deliver_verified_outcomes(
154
+ "verifier", **DELIVERY
155
+ )
156
+ if inspect.isawaitable(result):
157
+ await result
158
+ assert transport.post.call_count == 1
@@ -0,0 +1,42 @@
1
+ import pytest
2
+ from hebbrix.resources import _canonical_search_envelope
3
+
4
+
5
+ @pytest.mark.parametrize(
6
+ "patch",
7
+ [
8
+ {"sources": [{"content": "unidentified"}]},
9
+ {"sources": [None]},
10
+ {"sources": ["not a row"]},
11
+ {"sources": [{"memory_id": 7}]},
12
+ {"sources": [{"memory_id": " "}]},
13
+ {"sources": [{"memory_id": None, "id": "m-1"}]},
14
+ {"sources": [{"memory_id": "m-1", "id": "m-2"}]},
15
+ {"sources": {}},
16
+ {"sources": []},
17
+ {"evidence_ids": [7]},
18
+ {"evidence_ids": [None]},
19
+ {"evidence_ids": ["m-1", "m-1"]},
20
+ {"safety_contract_version": None},
21
+ {"safety_contract_version": "future-version"},
22
+ {"no_match": True},
23
+ ],
24
+ )
25
+ def test_malformed_evidence_never_exposes_unsupported_synthesis(patch):
26
+ response = {
27
+ "sources": [{"memory_id": "m-1", "content": "fact"}],
28
+ "answer": "unsupported answer",
29
+ "citations": ["m-1"],
30
+ "no_match": False,
31
+ "abstain_recommended": False,
32
+ "query_confidence": 0.9,
33
+ "grounding": {"status": "supported"},
34
+ "evidence_ids": ["m-1"],
35
+ "safety_contract_version": "search-safety-v1",
36
+ }
37
+ result = _canonical_search_envelope(dict(response, **patch), rows_key="sources")
38
+ assert result["sources"] == [] and result["evidence_ids"] == []
39
+ assert result["answer"] is None and result["citations"] == []
40
+ assert result["no_match"] is True and result["abstain_recommended"] is True
41
+ assert result["query_confidence"] == 0
42
+ assert response["sources"] # Caller-owned envelope was not mutated.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes