truststate 0.2.0__py3-none-any.whl

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.
truststate/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """
2
+ TrustState Python SDK
3
+ ~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ A Python SDK for TrustState compliance validation.
6
+
7
+ Basic usage::
8
+
9
+ import asyncio
10
+ from truststate import TrustStateClient
11
+
12
+ client = TrustStateClient(api_key="your-api-key")
13
+
14
+ async def main():
15
+ result = await client.check(
16
+ entity_type="AgentResponse",
17
+ data={"responseText": "Hello!", "confidenceScore": 0.95}
18
+ )
19
+ print(result.passed, result.record_id)
20
+
21
+ asyncio.run(main())
22
+ """
23
+
24
+ from .client import TrustStateClient
25
+ from .types import ComplianceResult, BatchResult, EvidenceItem
26
+ from .decorators import compliant
27
+ from .middleware import TrustStateMiddleware
28
+ from .exceptions import TrustStateError
29
+
30
+ __all__ = [
31
+ "TrustStateClient",
32
+ "ComplianceResult",
33
+ "BatchResult",
34
+ "EvidenceItem",
35
+ "compliant",
36
+ "TrustStateMiddleware",
37
+ "TrustStateError",
38
+ ]
39
+
40
+ __version__ = "0.2.0"
truststate/client.py ADDED
@@ -0,0 +1,500 @@
1
+ """TrustStateClient — async HTTP client for the TrustState compliance API.
2
+
3
+ Usage::
4
+
5
+ from truststate import TrustStateClient
6
+
7
+ client = TrustStateClient(api_key="your-key")
8
+ result = await client.check("AgentResponse", {"text": "...", "score": 0.9})
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import random
14
+ import uuid
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ import httpx
18
+
19
+ from .exceptions import TrustStateError
20
+ from .types import BatchResult, ComplianceResult, EvidenceItem
21
+
22
+
23
+ class TrustStateClient:
24
+ """Async client for the TrustState compliance validation API.
25
+
26
+ Args:
27
+ api_key: Your TrustState API key (used as X-API-Key header for writes).
28
+ base_url: Override the default API base URL.
29
+ default_schema_version: Schema version applied when not specified per-call.
30
+ default_actor_id: Actor ID applied when not specified per-call.
31
+ mock: If True, all HTTP calls are skipped and synthetic results are returned.
32
+ mock_pass_rate: Probability (0.0–1.0) that a mock check returns passed=True.
33
+ 1.0 = always pass, 0.0 = always fail, 0.8 = 80% pass rate.
34
+ timeout: HTTP request timeout in seconds.
35
+ """
36
+
37
+ DEFAULT_BASE_URL = "https://truststate-api.apps.trustchainlabs.com"
38
+
39
+ def __init__(
40
+ self,
41
+ api_key: str,
42
+ base_url: str = DEFAULT_BASE_URL,
43
+ default_schema_version: Optional[str] = None,
44
+ default_actor_id: str = "",
45
+ mock: bool = False,
46
+ mock_pass_rate: float = 1.0,
47
+ timeout: int = 30,
48
+ ) -> None:
49
+ self._api_key = api_key
50
+ self._base_url = base_url.rstrip("/")
51
+ self._default_schema_version = default_schema_version
52
+ self._default_actor_id = default_actor_id
53
+ self._mock = mock
54
+ self._mock_pass_rate = float(mock_pass_rate)
55
+ self._timeout = timeout
56
+
57
+ # ------------------------------------------------------------------
58
+ # Public API
59
+ # ------------------------------------------------------------------
60
+
61
+ async def check(
62
+ self,
63
+ entity_type: str,
64
+ data: Dict[str, Any],
65
+ action: str = "CREATE",
66
+ entity_id: Optional[str] = None,
67
+ schema_version: Optional[str] = None,
68
+ actor_id: Optional[str] = None,
69
+ ) -> ComplianceResult:
70
+ """Submit a single record for compliance checking.
71
+
72
+ Internally wraps the record in a one-item batch and calls POST /v1/write/batch.
73
+
74
+ Args:
75
+ entity_type: The type/category of the entity (e.g. "AgentResponse").
76
+ data: The record payload to validate.
77
+ action: The action being performed — "CREATE", "UPDATE", or "DELETE".
78
+ entity_id: Optional stable identifier for this entity. Auto-generated (uuid4)
79
+ if not provided.
80
+ schema_version: Override the client's default schema version.
81
+ actor_id: Override the client's default actor ID.
82
+
83
+ Returns:
84
+ ComplianceResult with pass/fail status and, if passed, a record_id.
85
+
86
+ Raises:
87
+ TrustStateError: On HTTP 4xx/5xx responses.
88
+ """
89
+ eid = entity_id or str(uuid.uuid4())
90
+
91
+ if self._mock:
92
+ return self._mock_single_result(eid)
93
+
94
+ batch_result = await self.check_batch(
95
+ items=[
96
+ {
97
+ "entity_type": entity_type,
98
+ "data": data,
99
+ "action": action,
100
+ "entity_id": eid,
101
+ "schema_version": schema_version,
102
+ "actor_id": actor_id,
103
+ }
104
+ ],
105
+ default_schema_version=schema_version,
106
+ default_actor_id=actor_id,
107
+ )
108
+ return batch_result.results[0]
109
+
110
+ async def check_batch(
111
+ self,
112
+ items: List[Dict[str, Any]],
113
+ default_schema_version: Optional[str] = None,
114
+ default_actor_id: Optional[str] = None,
115
+ feed_label: Optional[str] = None,
116
+ ) -> BatchResult:
117
+ """Submit multiple records for compliance checking in a single API call.
118
+
119
+ Args:
120
+ items: List of item dicts. Each may contain:
121
+ - entity_type (required)
122
+ - data (required)
123
+ - action (optional, default "upsert")
124
+ - entity_id (optional, auto-generated if absent)
125
+ - schema_version (optional — server auto-resolves to active schema if omitted)
126
+ - actor_id (optional)
127
+ default_schema_version: Fallback schema version for items that don't specify one.
128
+ If None, the server auto-resolves to the active schema for each entity type.
129
+ default_actor_id: Fallback actor ID for items that don't specify one.
130
+ feed_label: Optional label identifying this feed/source (e.g. "core-banking-feed").
131
+ Echoed back on every item result — useful for multi-feed pipelines.
132
+
133
+ Returns:
134
+ BatchResult summarising acceptance/rejection counts and per-item results.
135
+
136
+ Raises:
137
+ TrustStateError: On HTTP 4xx/5xx responses.
138
+ """
139
+ schema_ver = default_schema_version or self._default_schema_version
140
+ actor = default_actor_id or self._default_actor_id
141
+
142
+ # Normalise items and assign missing entity IDs
143
+ normalised = []
144
+ for item in items:
145
+ eid = item.get("entity_id") or str(uuid.uuid4())
146
+ entry: Dict[str, Any] = {
147
+ "entityType": item["entity_type"],
148
+ "data": item["data"],
149
+ "action": item.get("action", "upsert"),
150
+ "entityId": eid,
151
+ "actorId": item.get("actor_id") or actor or "sdk-writer",
152
+ }
153
+ sv = item.get("schema_version") or schema_ver
154
+ if sv:
155
+ entry["schemaVersion"] = sv
156
+ normalised.append(entry)
157
+
158
+ if self._mock:
159
+ return self._mock_batch_result(normalised, feed_label=feed_label)
160
+
161
+ payload: Dict[str, Any] = {"items": normalised}
162
+ if default_actor_id or self._default_actor_id:
163
+ payload["defaultActorId"] = default_actor_id or self._default_actor_id
164
+ if schema_ver:
165
+ payload["defaultSchemaVersion"] = schema_ver
166
+ if feed_label:
167
+ payload["feedLabel"] = feed_label
168
+
169
+ response_json = await self._post("/v1/write/batch", payload)
170
+ return self._parse_batch_response(response_json)
171
+
172
+ # ------------------------------------------------------------------
173
+ # BYOP Evidence fetch helpers
174
+ # ------------------------------------------------------------------
175
+
176
+ async def fetch_fx_rate(
177
+ self,
178
+ from_currency: str,
179
+ to_currency: str,
180
+ provider_id: str = "reuters-fx",
181
+ max_age_seconds: int = 300,
182
+ ) -> EvidenceItem:
183
+ """Fetch an FX rate oracle evidence item.
184
+
185
+ In mock mode returns a deterministic stub value (MYR/USD = 4.72).
186
+
187
+ Args:
188
+ from_currency: Source currency code (e.g. "MYR").
189
+ to_currency: Target currency code (e.g. "USD").
190
+ provider_id: Oracle provider ID (default "reuters-fx").
191
+ max_age_seconds: Max acceptable age in seconds (default 300).
192
+
193
+ Returns:
194
+ EvidenceItem ready to pass to check() or check_batch().
195
+ """
196
+ subject = {"from": from_currency, "to": to_currency}
197
+ if self._mock:
198
+ stub_rates = {"MYR_USD": 0.2119, "USD_MYR": 4.72, "EUR_USD": 1.085, "GBP_USD": 1.267}
199
+ key = f"{from_currency}_{to_currency}"
200
+ value = stub_rates.get(key, 1.0)
201
+ return EvidenceItem(
202
+ provider_id=provider_id,
203
+ provider_type="fx_rate",
204
+ subject=subject,
205
+ observed_value=value,
206
+ observed_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
207
+ max_age_seconds=max_age_seconds,
208
+ mock=True,
209
+ )
210
+ data = await self._get(f"/v1/oracle/fx-rate?from={from_currency}&to={to_currency}&providerId={provider_id}")
211
+ return EvidenceItem(
212
+ provider_id=data.get("providerId", provider_id),
213
+ provider_type="fx_rate",
214
+ subject=subject,
215
+ observed_value=data["observedValue"],
216
+ observed_at=data["observedAt"],
217
+ max_age_seconds=max_age_seconds,
218
+ proof_hash=data.get("proofHash"),
219
+ raw_proof_uri=data.get("rawProofUri"),
220
+ attestation=data.get("attestation"),
221
+ )
222
+
223
+ async def fetch_kyc_status(
224
+ self,
225
+ subject_id: str,
226
+ provider_id: str = "sumsub-kyc",
227
+ max_age_seconds: int = 86400,
228
+ ) -> EvidenceItem:
229
+ """Fetch a KYC status oracle evidence item.
230
+
231
+ Args:
232
+ subject_id: The entity/wallet/account being KYC-checked.
233
+ provider_id: Oracle provider ID (default "sumsub-kyc").
234
+ max_age_seconds: Max acceptable age (default 86400 = 24h).
235
+ """
236
+ subject = {"id": subject_id}
237
+ if self._mock:
238
+ return EvidenceItem(
239
+ provider_id=provider_id,
240
+ provider_type="kyc_status",
241
+ subject=subject,
242
+ observed_value="PASS",
243
+ observed_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
244
+ max_age_seconds=max_age_seconds,
245
+ mock=True,
246
+ )
247
+ data = await self._get(f"/v1/oracle/kyc-status?subjectId={subject_id}&providerId={provider_id}")
248
+ return EvidenceItem(
249
+ provider_id=data.get("providerId", provider_id),
250
+ provider_type="kyc_status",
251
+ subject=subject,
252
+ observed_value=data["observedValue"],
253
+ observed_at=data["observedAt"],
254
+ max_age_seconds=max_age_seconds,
255
+ proof_hash=data.get("proofHash"),
256
+ attestation=data.get("attestation"),
257
+ )
258
+
259
+ async def fetch_credit_score(
260
+ self,
261
+ subject_id: str,
262
+ provider_id: str = "coface-credit",
263
+ max_age_seconds: int = 86400,
264
+ ) -> EvidenceItem:
265
+ """Fetch a credit score oracle evidence item.
266
+
267
+ Args:
268
+ subject_id: Entity being scored.
269
+ provider_id: Oracle provider ID (default "coface-credit").
270
+ max_age_seconds: Max acceptable age (default 86400 = 24h).
271
+ """
272
+ subject = {"id": subject_id}
273
+ if self._mock:
274
+ return EvidenceItem(
275
+ provider_id=provider_id,
276
+ provider_type="credit_score",
277
+ subject=subject,
278
+ observed_value=720,
279
+ observed_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
280
+ max_age_seconds=max_age_seconds,
281
+ mock=True,
282
+ )
283
+ data = await self._get(f"/v1/oracle/credit-score?subjectId={subject_id}&providerId={provider_id}")
284
+ return EvidenceItem(
285
+ provider_id=data.get("providerId", provider_id),
286
+ provider_type="credit_score",
287
+ subject=subject,
288
+ observed_value=data["observedValue"],
289
+ observed_at=data["observedAt"],
290
+ max_age_seconds=max_age_seconds,
291
+ proof_hash=data.get("proofHash"),
292
+ attestation=data.get("attestation"),
293
+ )
294
+
295
+ async def fetch_sanctions(
296
+ self,
297
+ subject_id: str,
298
+ provider_id: str = "refinitiv-sanct",
299
+ max_age_seconds: int = 3600,
300
+ ) -> EvidenceItem:
301
+ """Fetch a sanctions screening oracle evidence item."""
302
+ subject = {"id": subject_id}
303
+ if self._mock:
304
+ return EvidenceItem(
305
+ provider_id=provider_id,
306
+ provider_type="sanctions",
307
+ subject=subject,
308
+ observed_value="CLEAR",
309
+ observed_at=__import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
310
+ max_age_seconds=max_age_seconds,
311
+ mock=True,
312
+ )
313
+ data = await self._get(f"/v1/oracle/sanctions?subjectId={subject_id}&providerId={provider_id}")
314
+ return EvidenceItem(
315
+ provider_id=data.get("providerId", provider_id),
316
+ provider_type="sanctions",
317
+ subject=subject,
318
+ observed_value=data["observedValue"],
319
+ observed_at=data["observedAt"],
320
+ max_age_seconds=max_age_seconds,
321
+ proof_hash=data.get("proofHash"),
322
+ attestation=data.get("attestation"),
323
+ )
324
+
325
+ async def check_with_evidence(
326
+ self,
327
+ entity_type: str,
328
+ data: Dict[str, Any],
329
+ evidence: List[EvidenceItem],
330
+ action: str = "CREATE",
331
+ entity_id: Optional[str] = None,
332
+ schema_version: Optional[str] = None,
333
+ actor_id: Optional[str] = None,
334
+ ) -> ComplianceResult:
335
+ """Submit a compliance check with oracle evidence attached.
336
+
337
+ Convenience wrapper around check() that serialises EvidenceItem objects
338
+ and bundles them in the write payload.
339
+
340
+ Example::
341
+
342
+ fx = await client.fetch_fx_rate("MYR", "USD")
343
+ kyc = await client.fetch_kyc_status("0x1234abcd")
344
+ result = await client.check_with_evidence(
345
+ "SukukBond",
346
+ {"issuerId": "...", "faceValue": 500000, "currency": "MYR"},
347
+ evidence=[fx, kyc],
348
+ )
349
+ """
350
+ eid = entity_id or str(uuid.uuid4())
351
+ schema_ver = schema_version or self._default_schema_version
352
+ actor = actor_id or self._default_actor_id
353
+
354
+ if self._mock:
355
+ return self._mock_single_result(eid)
356
+
357
+ item: Dict[str, Any] = {
358
+ "entityType": entity_type,
359
+ "data": data,
360
+ "action": action,
361
+ "entityId": eid,
362
+ "actorId": actor or "sdk-writer",
363
+ "evidence": [e.to_dict() for e in evidence],
364
+ }
365
+ if schema_ver:
366
+ item["schemaVersion"] = schema_ver
367
+ payload = {"items": [item]}
368
+ response_json = await self._post("/v1/write/batch", payload)
369
+ return self._parse_batch_response(response_json).results[0]
370
+
371
+ async def verify(self, record_id: str, bearer_token: str) -> Dict[str, Any]:
372
+ """Retrieve an immutable compliance record from the ledger.
373
+
374
+ Args:
375
+ record_id: The record ID returned by a previous check() that passed.
376
+ bearer_token: A valid Bearer token for the TrustState API.
377
+
378
+ Returns:
379
+ The full record dict from the API.
380
+
381
+ Raises:
382
+ TrustStateError: On HTTP 4xx/5xx responses.
383
+ """
384
+ url = f"{self._base_url}/v1/records/{record_id}"
385
+ headers = {"Authorization": f"Bearer {bearer_token}"}
386
+
387
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
388
+ try:
389
+ resp = await client.get(url, headers=headers)
390
+ except httpx.RequestError as exc:
391
+ raise TrustStateError(f"Network error: {exc}") from exc
392
+
393
+ if resp.status_code >= 400:
394
+ raise TrustStateError(
395
+ f"API error {resp.status_code}: {resp.text}", resp.status_code
396
+ )
397
+
398
+ return resp.json()
399
+
400
+ # ------------------------------------------------------------------
401
+ # Internal helpers
402
+ # ------------------------------------------------------------------
403
+
404
+ async def _get(self, path: str) -> Dict[str, Any]:
405
+ """Make an authenticated GET request and return the JSON response body."""
406
+ url = f"{self._base_url}{path}"
407
+ headers = {"X-API-Key": self._api_key}
408
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
409
+ try:
410
+ resp = await client.get(url, headers=headers)
411
+ except httpx.RequestError as exc:
412
+ raise TrustStateError(f"Network error: {exc}") from exc
413
+ if resp.status_code >= 400:
414
+ raise TrustStateError(f"API error {resp.status_code}: {resp.text}", resp.status_code)
415
+ return resp.json()
416
+
417
+ async def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
418
+ """Make an authenticated POST request and return the JSON response body."""
419
+ url = f"{self._base_url}{path}"
420
+ headers = {
421
+ "Content-Type": "application/json",
422
+ "X-API-Key": self._api_key,
423
+ }
424
+
425
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
426
+ try:
427
+ resp = await client.post(url, json=payload, headers=headers)
428
+ except httpx.RequestError as exc:
429
+ raise TrustStateError(f"Network error: {exc}") from exc
430
+
431
+ if resp.status_code >= 400:
432
+ raise TrustStateError(
433
+ f"API error {resp.status_code}: {resp.text}", resp.status_code
434
+ )
435
+
436
+ return resp.json()
437
+
438
+ def _parse_batch_response(self, data: Dict[str, Any]) -> BatchResult:
439
+ """Convert raw API JSON into a BatchResult."""
440
+ raw_results = data.get("results", [])
441
+ results = []
442
+ for r in raw_results:
443
+ accepted = r.get("status") == "accepted"
444
+ results.append(
445
+ ComplianceResult(
446
+ passed=accepted,
447
+ record_id=r.get("recordId"),
448
+ request_id=r.get("requestId", ""),
449
+ entity_id=r.get("entityId", ""),
450
+ fail_reason=r.get("failReason"),
451
+ failed_step=r.get("failedStep"),
452
+ feed_label=r.get("feedLabel"),
453
+ mock=False,
454
+ )
455
+ )
456
+
457
+ return BatchResult(
458
+ batch_id=data.get("batchId", str(uuid.uuid4())),
459
+ total=data.get("total", len(results)),
460
+ accepted=data.get("accepted", sum(1 for r in results if r.passed)),
461
+ rejected=data.get("rejected", sum(1 for r in results if not r.passed)),
462
+ results=results,
463
+ feed_label=data.get("feedLabel"),
464
+ mock=False,
465
+ )
466
+
467
+ # ------------------------------------------------------------------
468
+ # Mock helpers (no network calls)
469
+ # ------------------------------------------------------------------
470
+
471
+ def _mock_single_result(self, entity_id: str) -> ComplianceResult:
472
+ """Generate a synthetic ComplianceResult for mock mode."""
473
+ passed = random.random() < self._mock_pass_rate
474
+ return ComplianceResult(
475
+ passed=passed,
476
+ record_id=f"mock-rec-{uuid.uuid4()}" if passed else None,
477
+ request_id=f"mock-req-{uuid.uuid4()}",
478
+ entity_id=entity_id,
479
+ fail_reason=None if passed else "Mock: simulated policy failure",
480
+ failed_step=None if passed else 9,
481
+ mock=True,
482
+ )
483
+
484
+ def _mock_batch_result(self, normalised_items: List[Dict[str, Any]], feed_label: Optional[str] = None) -> BatchResult:
485
+ """Generate a synthetic BatchResult for mock mode."""
486
+ results = [
487
+ self._mock_single_result(item["entityId"]) for item in normalised_items
488
+ ]
489
+ for r in results:
490
+ r.feed_label = feed_label
491
+ accepted = sum(1 for r in results if r.passed)
492
+ return BatchResult(
493
+ batch_id=f"mock-batch-{uuid.uuid4()}",
494
+ total=len(results),
495
+ accepted=accepted,
496
+ rejected=len(results) - accepted,
497
+ results=results,
498
+ feed_label=feed_label,
499
+ mock=True,
500
+ )
@@ -0,0 +1,118 @@
1
+ """Decorators for automatic TrustState compliance checking.
2
+
3
+ Usage::
4
+
5
+ from truststate import TrustStateClient, compliant
6
+
7
+ ts = TrustStateClient(api_key="your-key")
8
+
9
+ @compliant(ts, entity_type="AgentResponse", action="CREATE")
10
+ async def generate_response(customer_id: str) -> dict:
11
+ return {"responseText": "Hello!", "confidenceScore": 0.95}
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import functools
17
+ import inspect
18
+ import logging
19
+ import warnings
20
+ from typing import Any, Callable, Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def compliant(
26
+ client: Any, # TrustStateClient — avoid circular import
27
+ entity_type: str,
28
+ action: str = "CREATE",
29
+ data_fn: Optional[Callable[[Any], dict]] = None,
30
+ on_fail: str = "raise",
31
+ ) -> Callable:
32
+ """Decorator that submits the wrapped function's return value to TrustState.
33
+
34
+ The decorated function runs first; its return value is then submitted for
35
+ compliance validation. Depending on ``on_fail``, a failed check either raises
36
+ an exception, logs a warning, or returns None.
37
+
38
+ Args:
39
+ client: A TrustStateClient instance.
40
+ entity_type: The entity type to register with TrustState.
41
+ action: The action string — "CREATE", "UPDATE", or "DELETE".
42
+ data_fn: Optional mapping function: ``data_fn(return_value) -> dict``.
43
+ If omitted, the return value must already be a dict (or have ``__dict__``).
44
+ on_fail: Behaviour when compliance check fails:
45
+ - ``"raise"`` — raise TrustStateError (default).
46
+ - ``"warn"`` — log a warning and return the original value anyway.
47
+ - ``"return_none"`` — silently return None.
48
+
49
+ Returns:
50
+ Decorated async function.
51
+
52
+ Raises:
53
+ ValueError: If on_fail is not a recognised value.
54
+ TrustStateError: (when on_fail="raise") if the compliance check fails.
55
+ """
56
+ if on_fail not in ("raise", "warn", "return_none"):
57
+ raise ValueError(f"on_fail must be 'raise', 'warn', or 'return_none'; got {on_fail!r}")
58
+
59
+ def decorator(func: Callable) -> Callable:
60
+ if not inspect.iscoroutinefunction(func):
61
+ raise TypeError(
62
+ f"@compliant requires an async function; {func.__name__} is not async."
63
+ )
64
+
65
+ @functools.wraps(func)
66
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
67
+ # Run the original function
68
+ result = await func(*args, **kwargs)
69
+
70
+ # Convert result to dict for submission
71
+ if data_fn is not None:
72
+ data = data_fn(result)
73
+ elif isinstance(result, dict):
74
+ data = result
75
+ elif hasattr(result, "__dict__"):
76
+ data = result.__dict__
77
+ else:
78
+ raise TypeError(
79
+ f"Cannot extract dict from return value of {func.__name__}. "
80
+ "Provide a data_fn or return a dict/dataclass."
81
+ )
82
+
83
+ # Submit to TrustState
84
+ compliance = await client.check(
85
+ entity_type=entity_type,
86
+ data=data,
87
+ action=action,
88
+ )
89
+
90
+ if compliance.passed:
91
+ return result
92
+
93
+ # Handle failure
94
+ if on_fail == "raise":
95
+ from .exceptions import TrustStateError
96
+ raise TrustStateError(
97
+ f"Compliance check failed for {entity_type}: "
98
+ f"{compliance.fail_reason} (step {compliance.failed_step})"
99
+ )
100
+ elif on_fail == "warn":
101
+ warnings.warn(
102
+ f"TrustState compliance failed for {entity_type} "
103
+ f"(entity_id={compliance.entity_id}): {compliance.fail_reason}",
104
+ stacklevel=2,
105
+ )
106
+ return result
107
+ else: # return_none
108
+ logger.debug(
109
+ "Compliance failed for %s entity_id=%s reason=%s — returning None",
110
+ entity_type,
111
+ compliance.entity_id,
112
+ compliance.fail_reason,
113
+ )
114
+ return None
115
+
116
+ return wrapper
117
+
118
+ return decorator
@@ -0,0 +1,18 @@
1
+ """Custom exceptions for the TrustState SDK."""
2
+
3
+
4
+ class TrustStateError(Exception):
5
+ """Raised when the TrustState API returns an error response.
6
+
7
+ Attributes:
8
+ message: Human-readable description of the error.
9
+ status_code: HTTP status code returned by the API (if applicable).
10
+ """
11
+
12
+ def __init__(self, message: str, status_code: int = 0) -> None:
13
+ super().__init__(message)
14
+ self.message = message
15
+ self.status_code = status_code
16
+
17
+ def __repr__(self) -> str:
18
+ return f"TrustStateError(message={self.message!r}, status_code={self.status_code})"
@@ -0,0 +1,127 @@
1
+ """FastAPI middleware for automatic TrustState compliance checking.
2
+
3
+ Usage::
4
+
5
+ from fastapi import FastAPI
6
+ from truststate import TrustStateClient, TrustStateMiddleware
7
+
8
+ app = FastAPI()
9
+ client = TrustStateClient(api_key="your-key")
10
+ app.add_middleware(TrustStateMiddleware, client=client)
11
+
12
+ Any request that includes the ``X-Compliance-Entity-Type`` header will have its
13
+ body submitted to TrustState before being passed to the route handler.
14
+ A failed compliance check returns HTTP 422.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ from typing import Any, Optional
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ try:
26
+ from starlette.middleware.base import BaseHTTPMiddleware
27
+ from starlette.requests import Request
28
+ from starlette.responses import JSONResponse, Response
29
+ from starlette.types import ASGIApp
30
+
31
+ _STARLETTE_AVAILABLE = True
32
+ except ImportError: # pragma: no cover
33
+ _STARLETTE_AVAILABLE = False
34
+ BaseHTTPMiddleware = object # type: ignore[misc,assignment]
35
+
36
+
37
+ class TrustStateMiddleware(BaseHTTPMiddleware): # type: ignore[misc]
38
+ """FastAPI/Starlette middleware that gates requests on TrustState compliance.
39
+
40
+ Inspects two custom headers on every incoming request:
41
+
42
+ - ``X-Compliance-Entity-Type``: The TrustState entity type to validate against.
43
+ - ``X-Compliance-Action``: The action string (default "CREATE").
44
+
45
+ If ``X-Compliance-Entity-Type`` is present, the raw request body is parsed as
46
+ JSON and submitted to TrustState. The request is allowed through only if the
47
+ compliance check passes; otherwise a 422 response is returned immediately.
48
+
49
+ Args:
50
+ app: The ASGI application to wrap.
51
+ client: A TrustStateClient instance.
52
+ entity_id_header: Optional request header that carries a stable entity ID.
53
+ If absent, one is auto-generated per request.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ app: Any,
59
+ client: Any, # TrustStateClient — avoid circular import
60
+ entity_id_header: str = "X-Compliance-Entity-Id",
61
+ ) -> None:
62
+ if not _STARLETTE_AVAILABLE:
63
+ raise ImportError(
64
+ "starlette is required for TrustStateMiddleware. "
65
+ "Install it with: pip install starlette"
66
+ )
67
+ super().__init__(app)
68
+ self._client = client
69
+ self._entity_id_header = entity_id_header
70
+
71
+ async def dispatch(self, request: Request, call_next: Any) -> Response:
72
+ entity_type: Optional[str] = request.headers.get("X-Compliance-Entity-Type")
73
+
74
+ # Pass through if the compliance header is absent
75
+ if not entity_type:
76
+ return await call_next(request)
77
+
78
+ action = request.headers.get("X-Compliance-Action", "CREATE")
79
+ entity_id = request.headers.get(self._entity_id_header)
80
+
81
+ # Read and parse the request body
82
+ try:
83
+ raw_body = await request.body()
84
+ data = json.loads(raw_body) if raw_body else {}
85
+ except (json.JSONDecodeError, ValueError) as exc:
86
+ logger.warning("TrustStateMiddleware: failed to parse request body: %s", exc)
87
+ return JSONResponse(
88
+ {"error": "Invalid JSON body for compliance check"},
89
+ status_code=400,
90
+ )
91
+
92
+ # Run compliance check
93
+ try:
94
+ result = await self._client.check(
95
+ entity_type=entity_type,
96
+ data=data,
97
+ action=action,
98
+ entity_id=entity_id or None,
99
+ )
100
+ except Exception as exc: # noqa: BLE001
101
+ logger.error("TrustStateMiddleware: compliance check error: %s", exc)
102
+ return JSONResponse(
103
+ {"error": "Compliance service unavailable", "detail": str(exc)},
104
+ status_code=503,
105
+ )
106
+
107
+ if not result.passed:
108
+ logger.info(
109
+ "TrustStateMiddleware: blocked request — entity_type=%s reason=%s",
110
+ entity_type,
111
+ result.fail_reason,
112
+ )
113
+ return JSONResponse(
114
+ {
115
+ "error": "Compliance check failed",
116
+ "fail_reason": result.fail_reason,
117
+ "failed_step": result.failed_step,
118
+ "entity_id": result.entity_id,
119
+ },
120
+ status_code=422,
121
+ )
122
+
123
+ # Attach the record ID so downstream handlers can use it
124
+ response = await call_next(request)
125
+ if result.record_id:
126
+ response.headers["X-Compliance-Record-Id"] = result.record_id
127
+ return response
truststate/types.py ADDED
@@ -0,0 +1,112 @@
1
+ """Data types for the TrustState SDK.
2
+
3
+ This module defines the result dataclasses returned by TrustStateClient.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import uuid as _uuid
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Dict, List, Optional
12
+
13
+
14
+ @dataclass
15
+ class ComplianceResult:
16
+ """Result of a single compliance check.
17
+
18
+ Attributes:
19
+ passed: True if the record passed all compliance checks.
20
+ record_id: Immutable ledger record ID — only set when passed=True.
21
+ request_id: Unique ID for this API request (useful for support/debugging).
22
+ entity_id: The entity ID that was submitted (caller-supplied or auto-generated).
23
+ fail_reason: Human-readable reason for failure — only set when passed=False.
24
+ failed_step: Numeric step that failed (8=schema validation, 9=policy check).
25
+ feed_label: The feed label from the batch request — useful for multi-feed pipelines.
26
+ mock: True when the result was synthesised locally in mock mode (no HTTP call made).
27
+ """
28
+
29
+ passed: bool
30
+ record_id: Optional[str]
31
+ request_id: str
32
+ entity_id: str
33
+ fail_reason: Optional[str]
34
+ failed_step: Optional[int]
35
+ feed_label: Optional[str] = None
36
+ mock: bool = False
37
+
38
+
39
+ @dataclass
40
+ class EvidenceItem:
41
+ """A single oracle evidence item to be submitted alongside a write.
42
+
43
+ Attributes:
44
+ evidence_id: Unique ID for this evidence item (auto-generated if not provided).
45
+ provider_id: Registered oracle provider ID (e.g. "reuters-fx").
46
+ provider_type: Oracle provider type (e.g. "fx_rate", "kyc_status").
47
+ subject: Key-value dict identifying the data subject (e.g. {"from": "MYR", "to": "USD"}).
48
+ observed_value: The oracle-reported value (numeric or string).
49
+ observed_at: ISO-8601 timestamp when the value was observed by the oracle.
50
+ retrieved_at: ISO-8601 timestamp when you fetched this value (defaults to now).
51
+ max_age_seconds: Maximum acceptable age of this evidence in seconds (default 300).
52
+ proof_hash: Optional sha256:<hex> hash of the raw proof document.
53
+ raw_proof_uri: Optional URL to the raw proof document for background verification.
54
+ attestation: Optional dict with ``type``, ``algorithm``, ``signature`` fields.
55
+ mock: True when synthesised locally (no real oracle call).
56
+ """
57
+
58
+ provider_id: str
59
+ provider_type: str
60
+ subject: Dict[str, Any]
61
+ observed_value: Any
62
+ observed_at: str
63
+ evidence_id: str = field(default_factory=lambda: str(_uuid.uuid4()))
64
+ retrieved_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
65
+ max_age_seconds: int = 300
66
+ proof_hash: Optional[str] = None
67
+ raw_proof_uri: Optional[str] = None
68
+ attestation: Optional[Dict[str, Any]] = None
69
+ mock: bool = False
70
+
71
+ def to_dict(self) -> Dict[str, Any]:
72
+ """Serialise to the wire format expected by the TrustState API."""
73
+ d: Dict[str, Any] = {
74
+ "evidenceId": self.evidence_id,
75
+ "providerId": self.provider_id,
76
+ "providerType": self.provider_type,
77
+ "subject": self.subject,
78
+ "observedValue": self.observed_value,
79
+ "observedAt": self.observed_at,
80
+ "retrievedAt": self.retrieved_at,
81
+ "maxAgeSeconds": self.max_age_seconds,
82
+ }
83
+ if self.proof_hash:
84
+ d["proofHash"] = self.proof_hash
85
+ if self.raw_proof_uri:
86
+ d["rawProofUri"] = self.raw_proof_uri
87
+ if self.attestation:
88
+ d["attestation"] = self.attestation
89
+ return d
90
+
91
+
92
+ @dataclass
93
+ class BatchResult:
94
+ """Aggregated result for a batch compliance submission.
95
+
96
+ Attributes:
97
+ batch_id: Unique ID for this batch request.
98
+ total: Total number of items submitted.
99
+ accepted: Number of items that passed compliance checks.
100
+ rejected: Number of items that failed compliance checks.
101
+ results: Per-item ComplianceResult list (same order as submitted items).
102
+ feed_label: The feed label for this batch (echoed from request).
103
+ mock: True when running in mock mode (no HTTP calls made).
104
+ """
105
+
106
+ batch_id: str
107
+ total: int
108
+ accepted: int
109
+ rejected: int
110
+ results: List[ComplianceResult]
111
+ feed_label: Optional[str] = None
112
+ mock: bool = False
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.4
2
+ Name: truststate
3
+ Version: 0.2.0
4
+ Summary: Python SDK for TrustState compliance validation
5
+ License: MIT
6
+ Project-URL: Homepage, https://trustchainlabs.com
7
+ Project-URL: Repository, https://github.com/MyreneBot/truststate-py
8
+ Project-URL: Bug Tracker, https://github.com/MyreneBot/truststate-py/issues
9
+ Keywords: compliance,AI governance,truststate,audit
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: dataclasses-json>=0.6
24
+ Provides-Extra: fastapi
25
+ Requires-Dist: starlette>=0.27; extra == "fastapi"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # TrustState Python SDK
32
+
33
+ [![PyPI version](https://img.shields.io/pypi/v/truststate.svg)](https://pypi.org/project/truststate/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/truststate.svg)](https://pypi.org/project/truststate/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
36
+
37
+ Python SDK for the [TrustState](https://truststate.apps.trustchainlabs.com) compliance API — validate, audit, and enforce compliance rules on any entity or data record. Built for financial services, AI governance, and regulated industries.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install truststate
43
+ ```
44
+
45
+ Requires Python 3.9+.
46
+
47
+ ## Quickstart
48
+
49
+ ```python
50
+ import asyncio
51
+ from truststate import TrustStateClient
52
+
53
+ client = TrustStateClient(api_key="ts_your_api_key")
54
+
55
+ async def main():
56
+ result = await client.check(
57
+ entity_type="SukukBond",
58
+ data={
59
+ "id": "BOND-001",
60
+ "issuerId": "ISS-001",
61
+ "currency": "MYR",
62
+ "faceValue": 5_000_000,
63
+ "maturityDate": "2030-06-01",
64
+ "status": "DRAFT",
65
+ },
66
+ )
67
+
68
+ if result.passed:
69
+ print(f"✅ Passed — record ID: {result.record_id}")
70
+ else:
71
+ print(f"❌ Failed — {result.fail_reason} (step {result.failed_step})")
72
+
73
+ asyncio.run(main())
74
+ ```
75
+
76
+ ## Batch Writes
77
+
78
+ Submit multiple records in a single API call. Useful for feed-based pipelines.
79
+
80
+ ```python
81
+ result = await client.check_batch(
82
+ items=[
83
+ {"entity_type": "SukukBond", "data": {"id": "BOND-001", ...}},
84
+ {"entity_type": "SukukBond", "data": {"id": "BOND-002", ...}},
85
+ {"entity_type": "SukukBond", "data": {"id": "BOND-003", ...}},
86
+ ],
87
+ feed_label="core-banking-feed", # echoed on every item result
88
+ )
89
+
90
+ print(f"Accepted: {result.accepted}/{result.total}")
91
+ for item in result.results:
92
+ print(f" {item.entity_id}: {'✅' if item.passed else '❌'} {item.feed_label}")
93
+ ```
94
+
95
+ ## BYOP Evidence (Oracle Data)
96
+
97
+ Attach oracle evidence to compliance checks — FX rates, KYC status, credit scores, sanctions screening.
98
+
99
+ ```python
100
+ # Fetch evidence from registered oracle providers
101
+ fx = await client.fetch_fx_rate("MYR", "USD")
102
+ kyc = await client.fetch_kyc_status("actor-jasim")
103
+ score = await client.fetch_credit_score("actor-jasim")
104
+
105
+ # Submit with evidence attached
106
+ result = await client.check_with_evidence(
107
+ entity_type="SukukBond",
108
+ data={"id": "BOND-001", "issuerId": "ISS-001", "currency": "MYR", "faceValue": 5_000_000},
109
+ evidence=[fx, kyc, score],
110
+ )
111
+ ```
112
+
113
+ ## Mock Mode
114
+
115
+ Test without making any API calls. Useful for unit tests and local development.
116
+
117
+ ```python
118
+ client = TrustStateClient(
119
+ api_key="any",
120
+ mock=True,
121
+ mock_pass_rate=0.8, # 80% of checks will pass
122
+ )
123
+
124
+ result = await client.check("SukukBond", {"id": "TEST-001", ...})
125
+ print(result.mock) # True
126
+ ```
127
+
128
+ ## Django / FastAPI Middleware
129
+
130
+ Automatically validate every incoming request body against TrustState policies.
131
+
132
+ ```python
133
+ # FastAPI
134
+ from truststate import TrustStateMiddleware
135
+
136
+ app.add_middleware(
137
+ TrustStateMiddleware,
138
+ api_key="ts_your_api_key",
139
+ entity_type="AgentResponse",
140
+ )
141
+
142
+ # Django
143
+ MIDDLEWARE = [
144
+ "truststate.middleware.TrustStateMiddleware",
145
+ ...
146
+ ]
147
+ TRUSTSTATE_API_KEY = "ts_your_api_key"
148
+ TRUSTSTATE_ENTITY_TYPE = "AgentResponse"
149
+ ```
150
+
151
+ ## `@compliant` Decorator
152
+
153
+ Wrap any async function to automatically submit its return value for compliance checking.
154
+
155
+ ```python
156
+ from truststate import compliant, TrustStateClient
157
+
158
+ client = TrustStateClient(api_key="ts_your_api_key")
159
+
160
+ @compliant(client=client, entity_type="AgentResponse")
161
+ async def generate_response(prompt: str) -> dict:
162
+ return {"text": "Hello!", "score": 0.95}
163
+
164
+ result = await generate_response("What is TrustState?")
165
+ # result is a ComplianceResult — .passed, .record_id, etc.
166
+ ```
167
+
168
+ ## Configuration
169
+
170
+ | Parameter | Type | Default | Description |
171
+ |---|---|---|---|
172
+ | `api_key` | `str` | required | Your TrustState API key |
173
+ | `base_url` | `str` | production URL | Override the API base URL |
174
+ | `default_schema_version` | `str \| None` | `None` | Schema version (auto-resolved if omitted) |
175
+ | `default_actor_id` | `str` | `""` | Actor ID for the audit trail |
176
+ | `mock` | `bool` | `False` | Enable mock mode (no HTTP calls) |
177
+ | `mock_pass_rate` | `float` | `1.0` | Pass probability in mock mode (0.0–1.0) |
178
+ | `timeout` | `int` | `30` | HTTP timeout in seconds |
179
+
180
+ ## API Reference
181
+
182
+ ### `check(entity_type, data, *, action, entity_id, schema_version, actor_id)`
183
+
184
+ Submit a single record for compliance checking.
185
+
186
+ Returns: `ComplianceResult`
187
+
188
+ ### `check_batch(items, *, default_schema_version, default_actor_id, feed_label)`
189
+
190
+ Submit up to 500 records in a single call.
191
+
192
+ Returns: `BatchResult`
193
+
194
+ ### `check_with_evidence(entity_type, data, evidence, *, action, entity_id, schema_version, actor_id)`
195
+
196
+ Submit a record with oracle evidence attached.
197
+
198
+ Returns: `ComplianceResult`
199
+
200
+ ### `fetch_fx_rate(from_currency, to_currency, *, provider_id, max_age_seconds)`
201
+ ### `fetch_kyc_status(subject_id, *, provider_id, max_age_seconds)`
202
+ ### `fetch_credit_score(subject_id, *, provider_id, max_age_seconds)`
203
+ ### `fetch_sanctions(subject_id, *, provider_id, max_age_seconds)`
204
+
205
+ Fetch oracle evidence items from registered providers.
206
+
207
+ Returns: `EvidenceItem`
208
+
209
+ ### `verify(record_id, bearer_token)`
210
+
211
+ Retrieve an immutable compliance record from the ledger.
212
+
213
+ Returns: `dict`
214
+
215
+ ## ComplianceResult
216
+
217
+ | Field | Type | Description |
218
+ |---|---|---|
219
+ | `passed` | `bool` | True if all checks passed |
220
+ | `record_id` | `str \| None` | Immutable ledger record ID (only when passed) |
221
+ | `request_id` | `str` | Unique API request ID |
222
+ | `entity_id` | `str` | Entity ID that was submitted |
223
+ | `fail_reason` | `str \| None` | Human-readable failure reason |
224
+ | `failed_step` | `int \| None` | Step that failed (8=schema, 9=policy) |
225
+ | `feed_label` | `str \| None` | Feed label from batch request |
226
+ | `mock` | `bool` | True if synthesised in mock mode |
227
+
228
+ ## Requirements
229
+
230
+ - Python 3.9+
231
+ - `httpx>=0.27`
232
+
233
+ ## License
234
+
235
+ MIT © Trustchain Labs
@@ -0,0 +1,11 @@
1
+ truststate/__init__.py,sha256=RkDdM4_OqTgHIZVmMj1mOF7eCuJ8xtf7jJf8WGSa25c,903
2
+ truststate/client.py,sha256=aq_KXiXivXFpeYcWZFo5ZxlAtMVrdDvK5ydoHXnJVQU,19577
3
+ truststate/decorators.py,sha256=oWImPCKN64X8bGs-ABXrn2hFdQHOgWzNpKudNesqOTs,4214
4
+ truststate/exceptions.py,sha256=gm27qY_kq3vbULR-7ZxP2anqC-DO8nEItcOM2g1kauU,604
5
+ truststate/middleware.py,sha256=Zv2aHgJY8oIXLWhfN-AK9GTboHD_IU2gQeGgSf8_mvI,4607
6
+ truststate/types.py,sha256=buX2PI9C-DfWLwc5DM8ja64ebiE21A6hb4NjN5R_MJg,4398
7
+ truststate-0.2.0.dist-info/licenses/LICENSE,sha256=3kMyI1A8h3bMejvRDgRx58bQT1KauBLKEnqxXMZX-tk,1066
8
+ truststate-0.2.0.dist-info/METADATA,sha256=f25K45vkgtvcJibDD5BXnRbQVGqU0Bp-6p1eWC8Pv6k,7190
9
+ truststate-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ truststate-0.2.0.dist-info/top_level.txt,sha256=YNcW7zAANI3PMnRsT4IBqd-Z0ZDlkcSCki0hnUCLSeM,11
11
+ truststate-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MyreneBot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ truststate