actproof 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.
actproof/receipt.py ADDED
@@ -0,0 +1,678 @@
1
+ # SPDX-FileCopyrightText: 2026 Deyan Paroushev
2
+ # SPDX-License-Identifier: MIT
3
+ """
4
+ Receipt: the artifact that travels outside the issuing platform.
5
+
6
+ A receipt wraps everything an independent verifier needs to confirm a
7
+ commitment was made and anchored: the canonical manifest, its hash, the
8
+ on-chain anchor record (network, txid, block round, the ARC-2 note payload),
9
+ the RFC 3161 trusted timestamp token, and the two profile discriminators
10
+ (``receipt_profile`` and ``batching_profile``). The receipt is the file
11
+ recipients receive, the file gets attached to compliance reports, the file
12
+ ends up in auditors' archives.
13
+
14
+ Two artifacts
15
+ -------------
16
+
17
+ This module models two distinct files an issuer produces per commitment:
18
+
19
+ 1. ``Receipt`` - the **public** artifact. Contains no plaintext emails or
20
+ internal identifiers. The manifest already uses hashed emails for
21
+ recipients; the receipt inherits that property. Safe to publish, attach
22
+ to outgoing email, or stash on a public webpage.
23
+
24
+ 2. ``IssuerEvidence`` - the **private** addendum the issuer keeps locally.
25
+ Carries the plaintext-to-hash mapping for recipient emails (so the
26
+ issuer can later identify "which auditor was on this anchor?") plus any
27
+ internal user identifiers. Linked to its public receipt by
28
+ ``manifest_hash``.
29
+
30
+ The two files are stored separately. The library reads and writes each
31
+ independently; the issuer's workflow is "anchor → write receipt JSON →
32
+ write issuer evidence JSON" as two operations.
33
+
34
+ Verification flow (implemented in v0.0.9)
35
+ -----------------------------------------
36
+
37
+ A verifier holding a receipt JSON file performs:
38
+
39
+ 1. Read the receipt with ``read_receipt(path)``.
40
+ 2. Recompute ``manifest_hash`` from ``receipt.manifest`` via
41
+ ``hash_manifest_hex(manifest)``. Compare to ``receipt.manifest_hash``.
42
+ This is the JCS-canonical hash check.
43
+ 3. Validate the manifest against the catalogue at the pinned
44
+ ``catalogue_git_commit`` (the verifier fetches the catalogue at that
45
+ commit).
46
+ 4. Look up ``receipt.anchor.txid`` on ``receipt.anchor.network``. Fetch
47
+ the transaction's note bytes. Strip the ``actproof:j`` ARC-2 prefix.
48
+ Compare to the base64-decoded ``receipt.anchor.note_payload_b64``.
49
+ 5. Verify ``receipt.trusted_timestamp.token_b64`` is a valid RFC 3161
50
+ token issued by the named TSA, whose imprint equals
51
+ ``receipt.manifest_hash``.
52
+
53
+ The verifier returns a structured ``VerificationResult`` (lands in v0.0.9).
54
+ This module is concerned with reading, writing, and structurally validating
55
+ receipts; the cryptographic verification lives elsewhere.
56
+
57
+ Reserved forward-compat slots
58
+ -----------------------------
59
+
60
+ The ``receipt_profile`` field is a discriminator. The v1 value
61
+ ``"actproof-jcs-v1"`` declares the JSON canonical layout this module
62
+ implements. A future v2 will introduce ``"actproof-scitt-cose-v2"`` once
63
+ RFC 9943 (SCITT Receipts) leaves AUTH48 and publishes. That format will
64
+ add a ``cose_sign1_b64`` field carrying a COSE_Sign1 signature over the
65
+ manifest hash, and a ``scitt_transparent_statement`` pointer to a SCITT
66
+ transparency service. Those fields are not present in v1 receipts; the
67
+ ``receipt_profile`` discriminator lets verifiers route to the right parser.
68
+
69
+ JSON file format
70
+ ----------------
71
+
72
+ Receipts are written as pretty-printed JSON (2-space indent) for human
73
+ readability. The verifier does NOT depend on receipt-file formatting:
74
+ the manifest within the receipt is re-canonicalised before hashing, so
75
+ whitespace in the receipt file is meaningless. Only the canonical manifest
76
+ bytes (computed by the verifier) feed into the hash check.
77
+
78
+ API
79
+ ---
80
+
81
+ * ``build_receipt(*, manifest, anchor, trusted_timestamp, ...) -> Receipt``
82
+ * ``receipt_to_dict(r) -> dict``
83
+ * ``receipt_from_dict(d) -> Receipt``
84
+ * ``read_receipt(path) -> Receipt``
85
+ * ``write_receipt(path, receipt) -> None``
86
+ * ``build_issuer_evidence(*, manifest_hash, ...) -> IssuerEvidence``
87
+ * ``issuer_evidence_to_dict(ev) -> dict``
88
+ * ``issuer_evidence_from_dict(d) -> IssuerEvidence``
89
+ * ``read_issuer_evidence(path) -> IssuerEvidence``
90
+ * ``write_issuer_evidence(path, evidence) -> None``
91
+
92
+ Exceptions
93
+ ~~~~~~~~~~
94
+
95
+ * ``ReceiptError`` - raised on I/O or parse problems.
96
+ """
97
+
98
+ from __future__ import annotations
99
+
100
+ import json
101
+ from dataclasses import dataclass
102
+ from pathlib import Path
103
+ from typing import Any, Mapping, Optional, Sequence
104
+
105
+ from actproof.manifest import (
106
+ BATCHING_PROFILE_SINGLE,
107
+ RECEIPT_PROFILE_V1,
108
+ Manifest,
109
+ hash_manifest_hex,
110
+ manifest_from_dict,
111
+ manifest_to_dict,
112
+ )
113
+
114
+ __all__ = [
115
+ "AnchorRecord",
116
+ "TimestampToken",
117
+ "Receipt",
118
+ "PlaintextRecipient",
119
+ "IssuerEvidence",
120
+ "ReceiptError",
121
+ "build_receipt",
122
+ "receipt_to_dict",
123
+ "receipt_from_dict",
124
+ "read_receipt",
125
+ "write_receipt",
126
+ "build_issuer_evidence",
127
+ "issuer_evidence_to_dict",
128
+ "issuer_evidence_from_dict",
129
+ "read_issuer_evidence",
130
+ "write_issuer_evidence",
131
+ "ALGORAND_MAINNET",
132
+ "ALGORAND_TESTNET",
133
+ "ALGORAND_BETANET",
134
+ "ARC2_NOTE_FORMAT",
135
+ "ARC2_DAPP_NAME",
136
+ "ARC2_FORMAT_VERSION_JSON",
137
+ ]
138
+
139
+
140
+ # ─────────────────────────────────────────────────────────────────
141
+ # CONSTANTS
142
+ # ─────────────────────────────────────────────────────────────────
143
+
144
+ ALGORAND_MAINNET: str = "algorand-mainnet"
145
+ ALGORAND_TESTNET: str = "algorand-testnet"
146
+ ALGORAND_BETANET: str = "algorand-betanet"
147
+
148
+ _VALID_NETWORKS: frozenset[str] = frozenset(
149
+ {ALGORAND_MAINNET, ALGORAND_TESTNET, ALGORAND_BETANET}
150
+ )
151
+
152
+ ARC2_NOTE_FORMAT: str = "arc-2"
153
+ """The note format identifier per Algorand ARC-2."""
154
+
155
+ ARC2_DAPP_NAME: str = "actproof"
156
+ """The dapp name actproof uses in the ARC-2 note prefix."""
157
+
158
+ ARC2_FORMAT_VERSION_JSON: str = "j"
159
+ """The ARC-2 format version for disclosed-mode JSON payloads."""
160
+
161
+
162
+ # ─────────────────────────────────────────────────────────────────
163
+ # EXCEPTIONS
164
+ # ─────────────────────────────────────────────────────────────────
165
+
166
+ class ReceiptError(ValueError):
167
+ """Raised when a receipt cannot be read, parsed, or has structural problems.
168
+
169
+ Subclass of ``ValueError`` so callers can catch ``ValueError`` for unified
170
+ handling, or ``ReceiptError`` specifically.
171
+ """
172
+
173
+
174
+ # ─────────────────────────────────────────────────────────────────
175
+ # DATA CLASSES
176
+ # ─────────────────────────────────────────────────────────────────
177
+
178
+ @dataclass(frozen=True)
179
+ class AnchorRecord:
180
+ """The on-chain anchor commitment for one manifest.
181
+
182
+ Carries everything a verifier needs to look up the transaction on the
183
+ right network and confirm the note bytes contain the expected manifest
184
+ hash. For unanchored draft receipts, ``block_round`` and ``confirmed_at``
185
+ are ``None`` and ``txid`` may be empty.
186
+
187
+ Attributes:
188
+ network: One of ``"algorand-mainnet"``, ``"algorand-testnet"``,
189
+ ``"algorand-betanet"``. Module-level constants
190
+ ``ALGORAND_MAINNET`` etc. spell these out.
191
+ txid: 52-character base32 Algorand transaction ID. Empty for draft
192
+ receipts (anchor not yet submitted).
193
+ block_round: Algorand round in which the transaction confirmed,
194
+ or ``None`` if not yet confirmed.
195
+ confirmed_at: ISO 8601 UTC timestamp of confirmation, or ``None``.
196
+ note_format: ARC-2 note format identifier. Always ``"arc-2"`` in v1.
197
+ note_dapp_name: ARC-2 dapp name prefix. Always ``"actproof"`` in v1.
198
+ note_format_version: ARC-2 format version. Always ``"j"`` (JSON
199
+ disclosed mode) in v1.
200
+ note_payload_b64: Base64 (standard, with padding) encoding of the
201
+ note payload bytes - the part AFTER the ARC-2 prefix
202
+ ``"actproof:j"``. Storing this lets a verifier reconstruct the
203
+ full on-chain note for byte-comparison without re-deriving from
204
+ the manifest hash.
205
+ """
206
+ network: str
207
+ txid: str
208
+ block_round: Optional[int]
209
+ confirmed_at: Optional[str]
210
+ note_format: str
211
+ note_dapp_name: str
212
+ note_format_version: str
213
+ note_payload_b64: str
214
+
215
+
216
+ @dataclass(frozen=True)
217
+ class TimestampToken:
218
+ """An RFC 3161 trusted timestamp token over the manifest hash.
219
+
220
+ The TSA's signed assertion that the manifest hash existed at a given
221
+ moment, vouched for by a certified TSA whose certificate chain anchors
222
+ in a trust list (EU Trust List, Adobe AATL, etc.). Storing the raw
223
+ DER-encoded token bytes lets any verifier re-check the signature.
224
+
225
+ Attributes:
226
+ tsa_url: HTTP(S) URL of the TSA the token came from. Useful for
227
+ diagnostics but NOT load-bearing for verification (the TSA
228
+ identity is inside the token itself).
229
+ tsa_name: Human-readable TSA name (e.g. ``"QuoVadis EU"``).
230
+ token_b64: Base64 (standard, with padding) of the DER-encoded
231
+ RFC 3161 TSToken. Load-bearing - this is what gets verified.
232
+ policy_oid: Policy OID under which the TSA issued the token. May
233
+ be ``None`` if the TSA did not assert a policy.
234
+ hash_alg: Hash algorithm used for the timestamp imprint, lowercase
235
+ (e.g. ``"sha-256"``). Must match the algorithm used to compute
236
+ the manifest hash.
237
+ imprint_hex: Lowercase hex of the imprint (typically the
238
+ ``manifest_hash`` raw bytes). The verifier checks this matches
239
+ the receipt's ``manifest_hash`` value.
240
+ timestamp: ISO 8601 UTC timestamp extracted from the token for
241
+ convenience. Authoritative value is inside ``token_b64``.
242
+ """
243
+ tsa_url: str
244
+ tsa_name: str
245
+ token_b64: str
246
+ policy_oid: Optional[str]
247
+ hash_alg: str
248
+ imprint_hex: str
249
+ timestamp: str
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class Receipt:
254
+ """A public actproof receipt. Contains no plaintext PII.
255
+
256
+ Bundles the canonical manifest, its content hash, the on-chain anchor
257
+ record, the RFC 3161 timestamp token, and the profile discriminators
258
+ that tell a verifier how to parse what it is reading.
259
+
260
+ Attributes:
261
+ receipt_profile: Discriminator for receipt layout. v1 is
262
+ ``"actproof-jcs-v1"``. The constant ``RECEIPT_PROFILE_V1``
263
+ from ``actproof.manifest`` spells this out.
264
+ issued_at: ISO 8601 UTC timestamp when the receipt was produced.
265
+ Typically equals ``manifest.issued_at``.
266
+ manifest: The full ``Manifest`` object. The verifier re-canonicalises
267
+ this to recompute the hash check.
268
+ manifest_hash: ``"sha256:..."`` of the canonical manifest bytes.
269
+ Cached at receipt-issue time and stored explicitly so verifiers
270
+ can spot manifest-substitution attacks at a glance.
271
+ anchor: On-chain anchor record.
272
+ trusted_timestamp: RFC 3161 token over the manifest hash.
273
+ batching_profile: ``"single_attestation_anchor_v1"`` for v1. The
274
+ constant ``BATCHING_PROFILE_SINGLE`` from ``actproof.manifest``
275
+ spells this out.
276
+ """
277
+ receipt_profile: str
278
+ issued_at: str
279
+ manifest: Manifest
280
+ manifest_hash: str
281
+ anchor: AnchorRecord
282
+ trusted_timestamp: TimestampToken
283
+ batching_profile: str
284
+
285
+
286
+ @dataclass(frozen=True)
287
+ class PlaintextRecipient:
288
+ """A recipient's plaintext email paired with the hash carried in the manifest.
289
+
290
+ Stored only inside ``IssuerEvidence``, never inside a public ``Receipt``.
291
+
292
+ Attributes:
293
+ email_plaintext: The recipient's email address as the issuer entered it.
294
+ email_hash: ``"sha256:..."`` computed via ``hash_email(email_plaintext)``.
295
+ Must equal one of the ``email_hash`` values in the corresponding
296
+ manifest's ``recipients`` list.
297
+ """
298
+ email_plaintext: str
299
+ email_hash: str
300
+
301
+
302
+ @dataclass(frozen=True)
303
+ class IssuerEvidence:
304
+ """The issuer's private addendum to a public receipt.
305
+
306
+ Carries plaintext recipient emails (so the issuer can later answer
307
+ "which auditor received this anchor?") and any internal user
308
+ identifiers. Linked to its public receipt by ``manifest_hash``.
309
+
310
+ NOT distributed. Lives in the issuer's vault, document management
311
+ system, or compliance archive. If the issuer is later asked by an
312
+ auditor "show me the original recipient list," they pull the
313
+ ``IssuerEvidence`` file from local storage.
314
+
315
+ Attributes:
316
+ receipt_profile: Same value as the linked ``Receipt.receipt_profile``,
317
+ so verifiers can confirm the addendum matches the receipt's profile.
318
+ manifest_hash: Same value as the linked ``Receipt.manifest_hash``.
319
+ This is what binds the addendum to the receipt.
320
+ plaintext_recipients: Tuple of plaintext-to-hash mappings, one per
321
+ recipient. Each ``email_hash`` MUST be present in the linked
322
+ manifest's recipients list (the verifier can check this).
323
+ issuer_user_id: Optional internal user identifier (Auth0 sub,
324
+ internal SSO ID, etc.) of the person at the issuing organisation
325
+ who actually clicked "anchor". Useful for internal audit; never
326
+ published.
327
+ internal_notes: Optional free-form text the issuer attaches at
328
+ anchor time (e.g. "reviewed by legal team 2026-05-13",
329
+ internal case reference).
330
+ """
331
+ receipt_profile: str
332
+ manifest_hash: str
333
+ plaintext_recipients: tuple[PlaintextRecipient, ...]
334
+ issuer_user_id: Optional[str]
335
+ internal_notes: Optional[str]
336
+
337
+
338
+ # ─────────────────────────────────────────────────────────────────
339
+ # CONSTRUCTION
340
+ # ─────────────────────────────────────────────────────────────────
341
+
342
+ def build_receipt(
343
+ *,
344
+ manifest: Manifest,
345
+ anchor: AnchorRecord,
346
+ trusted_timestamp: TimestampToken,
347
+ issued_at: Optional[str] = None,
348
+ receipt_profile: str = RECEIPT_PROFILE_V1,
349
+ batching_profile: str = BATCHING_PROFILE_SINGLE,
350
+ ) -> Receipt:
351
+ """Construct a ``Receipt`` from a manifest plus anchor and timestamp.
352
+
353
+ Computes ``manifest_hash`` from the canonical manifest bytes. Defaults
354
+ ``issued_at`` to the manifest's ``issued_at`` value if not provided.
355
+
356
+ Args:
357
+ manifest: The committed manifest.
358
+ anchor: The on-chain anchor record.
359
+ trusted_timestamp: The RFC 3161 timestamp token.
360
+ issued_at: When the receipt was produced. Defaults to
361
+ ``manifest.issued_at``.
362
+ receipt_profile: Receipt layout discriminator. Defaults to v1.
363
+ batching_profile: Batching discriminator. Defaults to single.
364
+
365
+ Returns:
366
+ A populated ``Receipt`` instance.
367
+ """
368
+ if issued_at is None:
369
+ issued_at = manifest.issued_at
370
+
371
+ manifest_hash = "sha256:" + hash_manifest_hex(manifest)
372
+
373
+ return Receipt(
374
+ receipt_profile=receipt_profile,
375
+ issued_at=issued_at,
376
+ manifest=manifest,
377
+ manifest_hash=manifest_hash,
378
+ anchor=anchor,
379
+ trusted_timestamp=trusted_timestamp,
380
+ batching_profile=batching_profile,
381
+ )
382
+
383
+
384
+ def build_issuer_evidence(
385
+ *,
386
+ manifest_hash: str,
387
+ plaintext_recipients: Sequence[PlaintextRecipient] = (),
388
+ issuer_user_id: Optional[str] = None,
389
+ internal_notes: Optional[str] = None,
390
+ receipt_profile: str = RECEIPT_PROFILE_V1,
391
+ ) -> IssuerEvidence:
392
+ """Construct an ``IssuerEvidence`` addendum linked to a public receipt.
393
+
394
+ Args:
395
+ manifest_hash: ``"sha256:..."`` of the public receipt's manifest.
396
+ Must match the linked ``Receipt.manifest_hash``.
397
+ plaintext_recipients: Plaintext-to-hash mappings for recipients.
398
+ issuer_user_id: Optional internal user identifier.
399
+ internal_notes: Optional free-form notes.
400
+ receipt_profile: Receipt profile discriminator (must match the
401
+ linked receipt's). Defaults to v1.
402
+
403
+ Returns:
404
+ A populated ``IssuerEvidence`` instance.
405
+ """
406
+ return IssuerEvidence(
407
+ receipt_profile=receipt_profile,
408
+ manifest_hash=manifest_hash,
409
+ plaintext_recipients=tuple(plaintext_recipients),
410
+ issuer_user_id=issuer_user_id,
411
+ internal_notes=internal_notes,
412
+ )
413
+
414
+
415
+ # ─────────────────────────────────────────────────────────────────
416
+ # SERIALISATION: AnchorRecord, TimestampToken
417
+ # ─────────────────────────────────────────────────────────────────
418
+
419
+ def _anchor_to_dict(a: AnchorRecord) -> dict[str, Any]:
420
+ return {
421
+ "network": a.network,
422
+ "txid": a.txid,
423
+ "block_round": a.block_round,
424
+ "confirmed_at": a.confirmed_at,
425
+ "note_format": a.note_format,
426
+ "note_dapp_name": a.note_dapp_name,
427
+ "note_format_version": a.note_format_version,
428
+ "note_payload_b64": a.note_payload_b64,
429
+ }
430
+
431
+
432
+ def _anchor_from_dict(d: Mapping[str, Any]) -> AnchorRecord:
433
+ try:
434
+ return AnchorRecord(
435
+ network=d["network"],
436
+ txid=d["txid"],
437
+ block_round=d["block_round"],
438
+ confirmed_at=d["confirmed_at"],
439
+ note_format=d["note_format"],
440
+ note_dapp_name=d["note_dapp_name"],
441
+ note_format_version=d["note_format_version"],
442
+ note_payload_b64=d["note_payload_b64"],
443
+ )
444
+ except (KeyError, TypeError, AttributeError) as exc:
445
+ raise ReceiptError(f"Cannot parse anchor record: {exc}") from exc
446
+
447
+
448
+ def _timestamp_to_dict(t: TimestampToken) -> dict[str, Any]:
449
+ return {
450
+ "tsa_url": t.tsa_url,
451
+ "tsa_name": t.tsa_name,
452
+ "token_b64": t.token_b64,
453
+ "policy_oid": t.policy_oid,
454
+ "hash_alg": t.hash_alg,
455
+ "imprint_hex": t.imprint_hex,
456
+ "timestamp": t.timestamp,
457
+ }
458
+
459
+
460
+ def _timestamp_from_dict(d: Mapping[str, Any]) -> TimestampToken:
461
+ try:
462
+ return TimestampToken(
463
+ tsa_url=d["tsa_url"],
464
+ tsa_name=d["tsa_name"],
465
+ token_b64=d["token_b64"],
466
+ policy_oid=d["policy_oid"],
467
+ hash_alg=d["hash_alg"],
468
+ imprint_hex=d["imprint_hex"],
469
+ timestamp=d["timestamp"],
470
+ )
471
+ except (KeyError, TypeError, AttributeError) as exc:
472
+ raise ReceiptError(f"Cannot parse timestamp token: {exc}") from exc
473
+
474
+
475
+ # ─────────────────────────────────────────────────────────────────
476
+ # SERIALISATION: Receipt
477
+ # ─────────────────────────────────────────────────────────────────
478
+
479
+ def receipt_to_dict(r: Receipt) -> dict[str, Any]:
480
+ """Convert a ``Receipt`` to a plain dict for JSON serialisation.
481
+
482
+ Args:
483
+ r: The receipt to serialise.
484
+
485
+ Returns:
486
+ A nested dict with all receipt fields.
487
+ """
488
+ return {
489
+ "receipt_profile": r.receipt_profile,
490
+ "issued_at": r.issued_at,
491
+ "manifest": manifest_to_dict(r.manifest),
492
+ "manifest_hash": r.manifest_hash,
493
+ "anchor": _anchor_to_dict(r.anchor),
494
+ "trusted_timestamp": _timestamp_to_dict(r.trusted_timestamp),
495
+ "batching_profile": r.batching_profile,
496
+ }
497
+
498
+
499
+ def receipt_from_dict(d: Mapping[str, Any]) -> Receipt:
500
+ """Parse a dict back into a ``Receipt``.
501
+
502
+ Used when reading a receipt from disk or from an external source.
503
+
504
+ Args:
505
+ d: A dict matching the receipt JSON layout.
506
+
507
+ Returns:
508
+ A reconstructed ``Receipt``.
509
+
510
+ Raises:
511
+ ReceiptError: If a required field is missing or has the wrong type.
512
+ """
513
+ try:
514
+ return Receipt(
515
+ receipt_profile=d["receipt_profile"],
516
+ issued_at=d["issued_at"],
517
+ manifest=manifest_from_dict(d["manifest"]),
518
+ manifest_hash=d["manifest_hash"],
519
+ anchor=_anchor_from_dict(d["anchor"]),
520
+ trusted_timestamp=_timestamp_from_dict(d["trusted_timestamp"]),
521
+ batching_profile=d["batching_profile"],
522
+ )
523
+ except (KeyError, TypeError, AttributeError) as exc:
524
+ raise ReceiptError(f"Cannot parse receipt: {exc}") from exc
525
+
526
+
527
+ # ─────────────────────────────────────────────────────────────────
528
+ # SERIALISATION: IssuerEvidence
529
+ # ─────────────────────────────────────────────────────────────────
530
+
531
+ def issuer_evidence_to_dict(ev: IssuerEvidence) -> dict[str, Any]:
532
+ """Convert an ``IssuerEvidence`` to a plain dict for JSON serialisation.
533
+
534
+ Args:
535
+ ev: The evidence to serialise.
536
+
537
+ Returns:
538
+ A nested dict.
539
+ """
540
+ return {
541
+ "receipt_profile": ev.receipt_profile,
542
+ "manifest_hash": ev.manifest_hash,
543
+ "plaintext_recipients": [
544
+ {
545
+ "email_plaintext": p.email_plaintext,
546
+ "email_hash": p.email_hash,
547
+ }
548
+ for p in ev.plaintext_recipients
549
+ ],
550
+ "issuer_user_id": ev.issuer_user_id,
551
+ "internal_notes": ev.internal_notes,
552
+ }
553
+
554
+
555
+ def issuer_evidence_from_dict(d: Mapping[str, Any]) -> IssuerEvidence:
556
+ """Parse a dict back into an ``IssuerEvidence``.
557
+
558
+ Args:
559
+ d: A dict matching the issuer evidence JSON layout.
560
+
561
+ Returns:
562
+ A reconstructed ``IssuerEvidence``.
563
+
564
+ Raises:
565
+ ReceiptError: If a required field is missing or has the wrong type.
566
+ """
567
+ try:
568
+ recipients = tuple(
569
+ PlaintextRecipient(
570
+ email_plaintext=p["email_plaintext"],
571
+ email_hash=p["email_hash"],
572
+ )
573
+ for p in d.get("plaintext_recipients", [])
574
+ )
575
+ return IssuerEvidence(
576
+ receipt_profile=d["receipt_profile"],
577
+ manifest_hash=d["manifest_hash"],
578
+ plaintext_recipients=recipients,
579
+ issuer_user_id=d.get("issuer_user_id"),
580
+ internal_notes=d.get("internal_notes"),
581
+ )
582
+ except (KeyError, TypeError, AttributeError) as exc:
583
+ raise ReceiptError(f"Cannot parse issuer evidence: {exc}") from exc
584
+
585
+
586
+ # ─────────────────────────────────────────────────────────────────
587
+ # FILESYSTEM I/O
588
+ # ─────────────────────────────────────────────────────────────────
589
+
590
+ def read_receipt(path: Path) -> Receipt:
591
+ """Read a receipt from a JSON file on disk.
592
+
593
+ Args:
594
+ path: Path to the receipt JSON file.
595
+
596
+ Returns:
597
+ The parsed ``Receipt``.
598
+
599
+ Raises:
600
+ ReceiptError: If the file cannot be read, parsed as JSON, or
601
+ interpreted as a receipt.
602
+ """
603
+ try:
604
+ raw_bytes = path.read_bytes()
605
+ except OSError as exc:
606
+ raise ReceiptError(f"Cannot read receipt file {path}: {exc}") from exc
607
+
608
+ try:
609
+ data = json.loads(raw_bytes)
610
+ except json.JSONDecodeError as exc:
611
+ raise ReceiptError(f"Receipt file {path} is not valid JSON: {exc}") from exc
612
+
613
+ return receipt_from_dict(data)
614
+
615
+
616
+ def write_receipt(path: Path, receipt: Receipt) -> None:
617
+ """Write a receipt to disk as pretty-printed JSON.
618
+
619
+ The 2-space indent makes the file human-readable; the verifier does NOT
620
+ depend on file formatting (the manifest within is re-canonicalised
621
+ before hashing).
622
+
623
+ Args:
624
+ path: Destination file path.
625
+ receipt: The receipt to write.
626
+
627
+ Raises:
628
+ ReceiptError: If the file cannot be written.
629
+ """
630
+ try:
631
+ data = receipt_to_dict(receipt)
632
+ text = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True)
633
+ path.write_text(text + "\n", encoding="utf-8")
634
+ except OSError as exc:
635
+ raise ReceiptError(f"Cannot write receipt to {path}: {exc}") from exc
636
+
637
+
638
+ def read_issuer_evidence(path: Path) -> IssuerEvidence:
639
+ """Read an issuer evidence addendum from disk.
640
+
641
+ Args:
642
+ path: Path to the issuer evidence JSON file.
643
+
644
+ Returns:
645
+ The parsed ``IssuerEvidence``.
646
+
647
+ Raises:
648
+ ReceiptError: If the file cannot be read or parsed.
649
+ """
650
+ try:
651
+ raw_bytes = path.read_bytes()
652
+ except OSError as exc:
653
+ raise ReceiptError(f"Cannot read evidence file {path}: {exc}") from exc
654
+
655
+ try:
656
+ data = json.loads(raw_bytes)
657
+ except json.JSONDecodeError as exc:
658
+ raise ReceiptError(f"Evidence file {path} is not valid JSON: {exc}") from exc
659
+
660
+ return issuer_evidence_from_dict(data)
661
+
662
+
663
+ def write_issuer_evidence(path: Path, evidence: IssuerEvidence) -> None:
664
+ """Write issuer evidence to disk as pretty-printed JSON.
665
+
666
+ Args:
667
+ path: Destination file path.
668
+ evidence: The evidence to write.
669
+
670
+ Raises:
671
+ ReceiptError: If the file cannot be written.
672
+ """
673
+ try:
674
+ data = issuer_evidence_to_dict(evidence)
675
+ text = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True)
676
+ path.write_text(text + "\n", encoding="utf-8")
677
+ except OSError as exc:
678
+ raise ReceiptError(f"Cannot write evidence to {path}: {exc}") from exc