ledgix-python 0.1.2__tar.gz → 0.1.3__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 (22) hide show
  1. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/PKG-INFO +2 -2
  2. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/README.md +1 -1
  3. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/pyproject.toml +1 -1
  4. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/__init__.py +6 -0
  5. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/client.py +211 -0
  6. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/models.py +54 -0
  7. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/test_client.py +182 -0
  8. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/.gitignore +0 -0
  9. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/demo.py +0 -0
  10. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/requirements.txt +0 -0
  11. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/adapters/__init__.py +0 -0
  12. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/adapters/crewai.py +0 -0
  13. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/adapters/langchain.py +0 -0
  14. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/adapters/llamaindex.py +0 -0
  15. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/config.py +0 -0
  16. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/enforce.py +0 -0
  17. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/exceptions.py +0 -0
  18. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/__init__.py +0 -0
  19. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/conftest.py +0 -0
  20. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/test_adapters.py +0 -0
  21. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/test_enforce.py +0 -0
  22. {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ledgix-python
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Agent-agnostic compliance shim for SOX 404 policy enforcement via the ALCV Vault
5
5
  Project-URL: Homepage, https://github.com/ledgix-dev/python-sdk
6
6
  Project-URL: Documentation, https://docs.ledgix.dev
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  # Ledgix ALCV — Python SDK
42
42
 
43
- [![PyPI](https://img.shields.io/pypi/v/ledgix-python)](https://pypi.org/project/ledgix-python/)
43
+ [![PyPI](https://img.shields.io/badge/pypi-v0.1.3-blue)](https://pypi.org/project/ledgix-python/)
44
44
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://python.org)
45
45
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
46
46
 
@@ -1,6 +1,6 @@
1
1
  # Ledgix ALCV — Python SDK
2
2
 
3
- [![PyPI](https://img.shields.io/pypi/v/ledgix-python)](https://pypi.org/project/ledgix-python/)
3
+ [![PyPI](https://img.shields.io/badge/pypi-v0.1.3-blue)](https://pypi.org/project/ledgix-python/)
4
4
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://python.org)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
6
6
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ledgix-python"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Agent-agnostic compliance shim for SOX 404 policy enforcement via the ALCV Vault"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -27,6 +27,9 @@ from .exceptions import (
27
27
  from .models import (
28
28
  ClearanceRequest,
29
29
  ClearanceResponse,
30
+ LedgerEntry,
31
+ LedgerManifest,
32
+ LedgerVerificationResult,
30
33
  PolicyRegistration,
31
34
  PolicyRegistrationResponse,
32
35
  )
@@ -43,6 +46,9 @@ __all__ = [
43
46
  # Models
44
47
  "ClearanceRequest",
45
48
  "ClearanceResponse",
49
+ "LedgerEntry",
50
+ "LedgerManifest",
51
+ "LedgerVerificationResult",
46
52
  "PolicyRegistration",
47
53
  "PolicyRegistrationResponse",
48
54
  # Exceptions
@@ -6,8 +6,11 @@ from __future__ import annotations
6
6
  import json
7
7
  import random
8
8
  import time
9
+ import hashlib
10
+ import base64
9
11
  from collections.abc import Awaitable, Callable
10
12
  from typing import Any
13
+ from urllib.parse import urlencode
11
14
 
12
15
  import httpx
13
16
  import jwt
@@ -25,6 +28,9 @@ from .exceptions import (
25
28
  from .models import (
26
29
  ClearanceRequest,
27
30
  ClearanceResponse,
31
+ LedgerEntry,
32
+ LedgerManifest,
33
+ LedgerVerificationResult,
28
34
  PolicyRegistration,
29
35
  PolicyRegistrationResponse,
30
36
  )
@@ -316,6 +322,106 @@ class LedgixClient:
316
322
  self._jwks_cache = response.json()
317
323
  return self._jwks_cache
318
324
 
325
+ def fetch_ledger(self, limit: int = 100) -> list[LedgerEntry]:
326
+ """Fetch recent ledger entries for the authenticated tenant (sync)."""
327
+ query = urlencode({"limit": max(1, min(limit, 500))})
328
+ try:
329
+ response = self._sync_retry(lambda: self._get_sync_client().get(f"/ledger?{query}"))
330
+ response.raise_for_status()
331
+ except httpx.HTTPStatusError as exc:
332
+ raise VaultConnectionError(
333
+ f"Failed to fetch ledger: HTTP {exc.response.status_code}"
334
+ ) from exc
335
+
336
+ payload = response.json()
337
+ return [LedgerEntry.model_validate(item) for item in payload.get("entries", [])]
338
+
339
+ async def afetch_ledger(self, limit: int = 100) -> list[LedgerEntry]:
340
+ """Fetch recent ledger entries for the authenticated tenant (async)."""
341
+ query = urlencode({"limit": max(1, min(limit, 500))})
342
+ try:
343
+ response = await self._async_retry(lambda: self._get_async_client().get(f"/ledger?{query}"))
344
+ response.raise_for_status()
345
+ except httpx.HTTPStatusError as exc:
346
+ raise VaultConnectionError(
347
+ f"Failed to fetch ledger: HTTP {exc.response.status_code}"
348
+ ) from exc
349
+
350
+ payload = response.json()
351
+ return [LedgerEntry.model_validate(item) for item in payload.get("entries", [])]
352
+
353
+ def fetch_ledger_manifests(self, limit: int = 24) -> list[LedgerManifest]:
354
+ """Fetch recent signed ledger manifests for the authenticated tenant (sync)."""
355
+ query = urlencode({"limit": max(1, min(limit, 500))})
356
+ try:
357
+ response = self._sync_retry(
358
+ lambda: self._get_sync_client().get(f"/ledger/manifests?{query}")
359
+ )
360
+ response.raise_for_status()
361
+ except httpx.HTTPStatusError as exc:
362
+ raise VaultConnectionError(
363
+ f"Failed to fetch ledger manifests: HTTP {exc.response.status_code}"
364
+ ) from exc
365
+
366
+ payload = response.json()
367
+ return [LedgerManifest.model_validate(item) for item in payload.get("manifests", [])]
368
+
369
+ async def afetch_ledger_manifests(self, limit: int = 24) -> list[LedgerManifest]:
370
+ """Fetch recent signed ledger manifests for the authenticated tenant (async)."""
371
+ query = urlencode({"limit": max(1, min(limit, 500))})
372
+ try:
373
+ response = await self._async_retry(
374
+ lambda: self._get_async_client().get(f"/ledger/manifests?{query}")
375
+ )
376
+ response.raise_for_status()
377
+ except httpx.HTTPStatusError as exc:
378
+ raise VaultConnectionError(
379
+ f"Failed to fetch ledger manifests: HTTP {exc.response.status_code}"
380
+ ) from exc
381
+
382
+ payload = response.json()
383
+ return [LedgerManifest.model_validate(item) for item in payload.get("manifests", [])]
384
+
385
+ def verify_ledger_proof(
386
+ self,
387
+ entries: list[LedgerEntry | dict[str, Any]] | None = None,
388
+ manifests: list[LedgerManifest | dict[str, Any]] | None = None,
389
+ ) -> LedgerVerificationResult:
390
+ """Verify ledger row receipts and manifest signatures offline using the Vault JWKS."""
391
+ entries = (
392
+ [item if isinstance(item, LedgerEntry) else LedgerEntry.model_validate(item) for item in entries]
393
+ if entries is not None
394
+ else self.fetch_ledger()
395
+ )
396
+ manifests = (
397
+ [item if isinstance(item, LedgerManifest) else LedgerManifest.model_validate(item) for item in manifests]
398
+ if manifests is not None
399
+ else self.fetch_ledger_manifests()
400
+ )
401
+ if self._jwks_cache is None:
402
+ self.fetch_jwks()
403
+ return self._verify_ledger_proof(entries, manifests)
404
+
405
+ async def averify_ledger_proof(
406
+ self,
407
+ entries: list[LedgerEntry | dict[str, Any]] | None = None,
408
+ manifests: list[LedgerManifest | dict[str, Any]] | None = None,
409
+ ) -> LedgerVerificationResult:
410
+ """Async variant of ``verify_ledger_proof``."""
411
+ entries = (
412
+ [item if isinstance(item, LedgerEntry) else LedgerEntry.model_validate(item) for item in entries]
413
+ if entries is not None
414
+ else await self.afetch_ledger()
415
+ )
416
+ manifests = (
417
+ [item if isinstance(item, LedgerManifest) else LedgerManifest.model_validate(item) for item in manifests]
418
+ if manifests is not None
419
+ else await self.afetch_ledger_manifests()
420
+ )
421
+ if self._jwks_cache is None:
422
+ await self.afetch_jwks()
423
+ return self._verify_ledger_proof(entries, manifests)
424
+
319
425
  def verify_token(self, token: str) -> dict[str, Any]:
320
426
  """Verify an A-JWT using the Vault's public key (sync).
321
427
 
@@ -374,6 +480,111 @@ class LedgixClient:
374
480
  except Exception as exc:
375
481
  raise TokenVerificationError(f"Token verification failed: {exc}") from exc
376
482
 
483
+ def _verify_ledger_proof(
484
+ self,
485
+ entries: list[LedgerEntry],
486
+ manifests: list[LedgerManifest],
487
+ ) -> LedgerVerificationResult:
488
+ if not self._jwks_cache:
489
+ return LedgerVerificationResult(
490
+ intact=False,
491
+ verified_entries=0,
492
+ verified_manifests=0,
493
+ latest_row_hash=None,
494
+ latest_manifest_hash=None,
495
+ error="No JWKS available from Vault",
496
+ )
497
+
498
+ try:
499
+ jwks = self._jwks_cache
500
+ keys = jwks.get("keys") if isinstance(jwks, dict) else None
501
+ if not isinstance(keys, list) or not keys:
502
+ raise TokenVerificationError("JWKS contains no keys")
503
+
504
+ algorithm = jwt.algorithms.get_default_algorithms()["EdDSA"]
505
+ key_cache: dict[str, Any] = {}
506
+
507
+ def key_for_kid(kid: str) -> Any:
508
+ if kid in key_cache:
509
+ return key_cache[kid]
510
+ match = next(
511
+ (
512
+ item
513
+ for item in keys
514
+ if isinstance(item, dict) and item.get("kid") == kid
515
+ ),
516
+ None,
517
+ )
518
+ if match is None:
519
+ raise TokenVerificationError(f"No public key found for kid {kid}")
520
+ public_key = jwt.algorithms.OKPAlgorithm.from_jwk(json.dumps(match))
521
+ key_cache[kid] = public_key
522
+ return public_key
523
+
524
+ previous_row_hash = "0" * 64
525
+ sorted_entries = sorted(entries, key=lambda item: item.seq)
526
+ for entry in sorted_entries:
527
+ if entry.prev_row_hash != previous_row_hash:
528
+ raise TokenVerificationError(f"Ledger chain broken at seq {entry.seq}")
529
+ if not entry.receipt_payload or not entry.row_signature or not entry.signer_key_id:
530
+ raise TokenVerificationError(f"Missing receipt proof data at seq {entry.seq}")
531
+ if not algorithm.verify(
532
+ self._decode_base64url(entry.receipt_payload),
533
+ key_for_kid(entry.signer_key_id),
534
+ self._decode_base64url(entry.row_signature),
535
+ ):
536
+ raise TokenVerificationError(f"Ledger receipt signature invalid at seq {entry.seq}")
537
+ previous_row_hash = entry.row_hash
538
+
539
+ previous_manifest_hash = "sha256:" + ("0" * 64)
540
+ sorted_manifests = sorted(manifests, key=lambda item: item.period_start)
541
+ for manifest in sorted_manifests:
542
+ if manifest.prev_manifest_hash != previous_manifest_hash:
543
+ raise TokenVerificationError(
544
+ f"Manifest chain broken at {manifest.period_start}"
545
+ )
546
+ if not manifest.manifest_payload or not manifest.manifest_signature or not manifest.signer_key_id:
547
+ raise TokenVerificationError(
548
+ f"Missing manifest proof data at {manifest.period_start}"
549
+ )
550
+ payload_bytes = self._decode_base64url(manifest.manifest_payload)
551
+ if not algorithm.verify(
552
+ payload_bytes,
553
+ key_for_kid(manifest.signer_key_id),
554
+ self._decode_base64url(manifest.manifest_signature),
555
+ ):
556
+ raise TokenVerificationError(
557
+ f"Manifest signature invalid at {manifest.period_start}"
558
+ )
559
+ payload_hash = hashlib.sha256(payload_bytes).hexdigest()
560
+ if f"sha256:{payload_hash}" != manifest.manifest_hash:
561
+ raise TokenVerificationError(
562
+ f"Manifest hash mismatch at {manifest.period_start}"
563
+ )
564
+ previous_manifest_hash = manifest.manifest_hash
565
+
566
+ return LedgerVerificationResult(
567
+ intact=True,
568
+ verified_entries=len(sorted_entries),
569
+ verified_manifests=len(sorted_manifests),
570
+ latest_row_hash=sorted_entries[-1].row_hash if sorted_entries else None,
571
+ latest_manifest_hash=sorted_manifests[-1].manifest_hash if sorted_manifests else None,
572
+ )
573
+ except Exception as exc:
574
+ return LedgerVerificationResult(
575
+ intact=False,
576
+ verified_entries=0,
577
+ verified_manifests=0,
578
+ latest_row_hash=None,
579
+ latest_manifest_hash=None,
580
+ error=str(exc),
581
+ )
582
+
583
+ @staticmethod
584
+ def _decode_base64url(value: str) -> bytes:
585
+ padded = value + "=" * ((4 - len(value) % 4) % 4)
586
+ return base64.urlsafe_b64decode(padded.encode("ascii"))
587
+
377
588
  # ------------------------------------------------------------------
378
589
  # Lifecycle
379
590
  # ------------------------------------------------------------------
@@ -63,3 +63,57 @@ class PolicyRegistrationResponse(BaseModel):
63
63
  policy_id: str = Field(..., description="Confirmed policy ID")
64
64
  status: str = Field(default="registered", description="Registration status")
65
65
  message: str = Field(default="", description="Additional information")
66
+
67
+
68
+ class LedgerEntry(BaseModel):
69
+ """Ledger entry returned by the Vault's ledger endpoints."""
70
+
71
+ seq: int
72
+ request_id: str
73
+ agent_id: str = ""
74
+ policy_id: str = ""
75
+ intent_hash: str = ""
76
+ tool_name: str
77
+ tool_args: dict[str, Any] = Field(default_factory=dict)
78
+ reason: str = ""
79
+ citations: list[dict[str, Any]] = Field(default_factory=list)
80
+ evidence_chunks: list[dict[str, Any]] = Field(default_factory=list)
81
+ confidence: float = Field(default=0.0, ge=0.0, le=1.0)
82
+ approved: bool
83
+ decided_at: str
84
+ prev_row_hash: str = ""
85
+ row_hash: str
86
+ signature_algorithm: str = ""
87
+ signer_key_id: str = ""
88
+ row_signature: str = ""
89
+ receipt_payload: str = ""
90
+
91
+
92
+ class LedgerManifest(BaseModel):
93
+ """Signed chain-head manifest returned by the Vault."""
94
+
95
+ period_start: str
96
+ period_end_exclusive: str
97
+ generated_at: str
98
+ head_seq: int
99
+ head_row_hash: str
100
+ head_row_signature: str = ""
101
+ manifest_hash: str
102
+ prev_manifest_hash: str = ""
103
+ signature_algorithm: str = ""
104
+ signer_key_id: str = ""
105
+ manifest_signature: str = ""
106
+ manifest_payload: str = ""
107
+ anchor_uri: str = ""
108
+ anchored_at: str | None = None
109
+
110
+
111
+ class LedgerVerificationResult(BaseModel):
112
+ """Result of independent offline ledger verification."""
113
+
114
+ intact: bool
115
+ verified_entries: int
116
+ verified_manifests: int
117
+ latest_row_hash: str | None = None
118
+ latest_manifest_hash: str | None = None
119
+ error: str | None = None
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import base64
6
+ import hashlib
5
7
  import json
6
8
 
7
9
  import httpx
@@ -309,6 +311,186 @@ class TestTokenVerification:
309
311
  assert result.approved is True
310
312
 
311
313
 
314
+ class TestLedgerProofVerification:
315
+ """Tests for ledger fetch + offline proof verification."""
316
+
317
+ @staticmethod
318
+ def _b64url(value: bytes) -> str:
319
+ return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
320
+
321
+ @respx.mock
322
+ def test_fetch_ledger_and_manifests(self, client: LedgixClient):
323
+ respx.get("https://vault.test/ledger?limit=2").mock(
324
+ return_value=Response(
325
+ 200,
326
+ json={
327
+ "entries": [
328
+ {
329
+ "seq": 2,
330
+ "request_id": "req-2",
331
+ "tool_name": "stripe_refund",
332
+ "approved": True,
333
+ "decided_at": "2026-03-15T12:00:00Z",
334
+ "row_hash": "b" * 64,
335
+ }
336
+ ]
337
+ },
338
+ )
339
+ )
340
+ respx.get("https://vault.test/ledger/manifests?limit=3").mock(
341
+ return_value=Response(
342
+ 200,
343
+ json={
344
+ "manifests": [
345
+ {
346
+ "period_start": "2026-03-15T12:00:00Z",
347
+ "period_end_exclusive": "2026-03-15T13:00:00Z",
348
+ "generated_at": "2026-03-15T13:00:00Z",
349
+ "head_seq": 2,
350
+ "head_row_hash": "b" * 64,
351
+ "manifest_hash": "sha256:" + ("c" * 64),
352
+ }
353
+ ]
354
+ },
355
+ )
356
+ )
357
+
358
+ entries = client.fetch_ledger(limit=2)
359
+ manifests = client.fetch_ledger_manifests(limit=3)
360
+
361
+ assert len(entries) == 1
362
+ assert entries[0].request_id == "req-2"
363
+ assert len(manifests) == 1
364
+ assert manifests[0].head_seq == 2
365
+
366
+ @respx.mock
367
+ def test_verify_ledger_proof(
368
+ self,
369
+ client: LedgixClient,
370
+ ed25519_private_key,
371
+ jwks_response: dict,
372
+ ):
373
+ row_payload = b'{"client_id":"demo","seq":1,"row_hash":"' + ("a" * 64).encode("ascii") + b'"}'
374
+ row_signature = ed25519_private_key.sign(row_payload)
375
+ manifest_payload = (
376
+ b'{"period_start":"2026-03-15T12:00:00Z","head_seq":1,"head_row_hash":"'
377
+ + ("a" * 64).encode("ascii")
378
+ + b'"}'
379
+ )
380
+ manifest_signature = ed25519_private_key.sign(manifest_payload)
381
+ manifest_hash = "sha256:" + hashlib.sha256(manifest_payload).hexdigest()
382
+
383
+ respx.get("https://vault.test/.well-known/jwks.json").mock(
384
+ return_value=Response(200, json=jwks_response)
385
+ )
386
+
387
+ result = client.verify_ledger_proof(
388
+ entries=[
389
+ {
390
+ "seq": 1,
391
+ "request_id": "req-1",
392
+ "agent_id": "agent-1",
393
+ "policy_id": "policy-1",
394
+ "tool_name": "stripe_refund",
395
+ "tool_args": {"amount": 45},
396
+ "reason": "ok",
397
+ "approved": True,
398
+ "confidence": 0.91,
399
+ "decided_at": "2026-03-15T12:00:00Z",
400
+ "prev_row_hash": "0" * 64,
401
+ "row_hash": "a" * 64,
402
+ "signer_key_id": "test-key-001",
403
+ "row_signature": self._b64url(row_signature),
404
+ "receipt_payload": self._b64url(row_payload),
405
+ }
406
+ ],
407
+ manifests=[
408
+ {
409
+ "period_start": "2026-03-15T12:00:00Z",
410
+ "period_end_exclusive": "2026-03-15T13:00:00Z",
411
+ "generated_at": "2026-03-15T13:00:00Z",
412
+ "head_seq": 1,
413
+ "head_row_hash": "a" * 64,
414
+ "head_row_signature": self._b64url(row_signature),
415
+ "manifest_hash": manifest_hash,
416
+ "prev_manifest_hash": "sha256:" + ("0" * 64),
417
+ "signer_key_id": "test-key-001",
418
+ "manifest_signature": self._b64url(manifest_signature),
419
+ "manifest_payload": self._b64url(manifest_payload),
420
+ "anchor_uri": "s3://ledgix-ledger/demo/2026-03-15T13:00:00Z.json",
421
+ "anchored_at": "2026-03-15T13:00:30Z",
422
+ }
423
+ ],
424
+ )
425
+
426
+ assert result.intact is True
427
+ assert result.verified_entries == 1
428
+ assert result.verified_manifests == 1
429
+ assert result.latest_row_hash == "a" * 64
430
+
431
+ @respx.mock
432
+ @pytest.mark.asyncio
433
+ async def test_verify_ledger_proof_async(
434
+ self,
435
+ client: LedgixClient,
436
+ ed25519_private_key,
437
+ jwks_response: dict,
438
+ ):
439
+ row_payload = b'{"client_id":"demo","seq":2,"row_hash":"' + ("b" * 64).encode("ascii") + b'"}'
440
+ row_signature = ed25519_private_key.sign(row_payload)
441
+ manifest_payload = (
442
+ b'{"period_start":"2026-03-15T13:00:00Z","head_seq":2,"head_row_hash":"'
443
+ + ("b" * 64).encode("ascii")
444
+ + b'"}'
445
+ )
446
+ manifest_signature = ed25519_private_key.sign(manifest_payload)
447
+ manifest_hash = "sha256:" + hashlib.sha256(manifest_payload).hexdigest()
448
+
449
+ respx.get("https://vault.test/.well-known/jwks.json").mock(
450
+ return_value=Response(200, json=jwks_response)
451
+ )
452
+
453
+ result = await client.averify_ledger_proof(
454
+ entries=[
455
+ {
456
+ "seq": 2,
457
+ "request_id": "req-2",
458
+ "agent_id": "agent-2",
459
+ "policy_id": "policy-2",
460
+ "tool_name": "stripe_refund",
461
+ "tool_args": {"amount": 60},
462
+ "reason": "ok",
463
+ "approved": True,
464
+ "confidence": 0.88,
465
+ "decided_at": "2026-03-15T13:00:00Z",
466
+ "prev_row_hash": "0" * 64,
467
+ "row_hash": "b" * 64,
468
+ "signer_key_id": "test-key-001",
469
+ "row_signature": self._b64url(row_signature),
470
+ "receipt_payload": self._b64url(row_payload),
471
+ }
472
+ ],
473
+ manifests=[
474
+ {
475
+ "period_start": "2026-03-15T13:00:00Z",
476
+ "period_end_exclusive": "2026-03-15T14:00:00Z",
477
+ "generated_at": "2026-03-15T14:00:00Z",
478
+ "head_seq": 2,
479
+ "head_row_hash": "b" * 64,
480
+ "head_row_signature": self._b64url(row_signature),
481
+ "manifest_hash": manifest_hash,
482
+ "prev_manifest_hash": "sha256:" + ("0" * 64),
483
+ "signer_key_id": "test-key-001",
484
+ "manifest_signature": self._b64url(manifest_signature),
485
+ "manifest_payload": self._b64url(manifest_payload),
486
+ }
487
+ ],
488
+ )
489
+
490
+ assert result.intact is True
491
+ assert result.latest_manifest_hash == manifest_hash
492
+
493
+
312
494
  # ──────────────────────────────────────────────────────────────────────
313
495
  # Client lifecycle
314
496
  # ──────────────────────────────────────────────────────────────────────
File without changes
File without changes