actproof 0.3.2__tar.gz → 0.3.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 (40) hide show
  1. {actproof-0.3.2 → actproof-0.3.3}/PKG-INFO +3 -1
  2. {actproof-0.3.2 → actproof-0.3.3}/actproof/__init__.py +1 -1
  3. {actproof-0.3.2 → actproof-0.3.3}/actproof/catalogue.py +229 -28
  4. {actproof-0.3.2 → actproof-0.3.3}/actproof/manifest.py +130 -10
  5. {actproof-0.3.2 → actproof-0.3.3}/pyproject.toml +3 -1
  6. actproof-0.3.3/tests/test_catalogue_package_provenance_bugfix.py +518 -0
  7. {actproof-0.3.2 → actproof-0.3.3}/tests/test_catalogue_v3.py +31 -14
  8. {actproof-0.3.2 → actproof-0.3.3}/tests/test_signers_google_kms.py +24 -0
  9. {actproof-0.3.2 → actproof-0.3.3}/.gitignore +0 -0
  10. {actproof-0.3.2 → actproof-0.3.3}/CHANGELOG.md +0 -0
  11. {actproof-0.3.2 → actproof-0.3.3}/DEPLOY.md +0 -0
  12. {actproof-0.3.2 → actproof-0.3.3}/LICENSE +0 -0
  13. {actproof-0.3.2 → actproof-0.3.3}/README.md +0 -0
  14. {actproof-0.3.2 → actproof-0.3.3}/SECURITY.md +0 -0
  15. {actproof-0.3.2 → actproof-0.3.3}/actproof/anchor.py +0 -0
  16. {actproof-0.3.2 → actproof-0.3.3}/actproof/canonical.py +0 -0
  17. {actproof-0.3.2 → actproof-0.3.3}/actproof/cli.py +0 -0
  18. {actproof-0.3.2 → actproof-0.3.3}/actproof/py.typed +0 -0
  19. {actproof-0.3.2 → actproof-0.3.3}/actproof/receipt.py +0 -0
  20. {actproof-0.3.2 → actproof-0.3.3}/actproof/signers/__init__.py +0 -0
  21. {actproof-0.3.2 → actproof-0.3.3}/actproof/signers/google_kms.py +0 -0
  22. {actproof-0.3.2 → actproof-0.3.3}/actproof/signers/interface.py +0 -0
  23. {actproof-0.3.2 → actproof-0.3.3}/actproof/signers/mnemonic.py +0 -0
  24. {actproof-0.3.2 → actproof-0.3.3}/actproof/timestamp.py +0 -0
  25. {actproof-0.3.2 → actproof-0.3.3}/actproof/verify.py +0 -0
  26. {actproof-0.3.2 → actproof-0.3.3}/docs/ANCHOR_BACKENDS.md +0 -0
  27. {actproof-0.3.2 → actproof-0.3.3}/docs/INTEGRATION.md +0 -0
  28. {actproof-0.3.2 → actproof-0.3.3}/docs/STS-STANDARDS-APPLICATION.md +0 -0
  29. {actproof-0.3.2 → actproof-0.3.3}/tests/__init__.py +0 -0
  30. {actproof-0.3.2 → actproof-0.3.3}/tests/test_anchor.py +0 -0
  31. {actproof-0.3.2 → actproof-0.3.3}/tests/test_canonical.py +0 -0
  32. {actproof-0.3.2 → actproof-0.3.3}/tests/test_catalogue.py +0 -0
  33. {actproof-0.3.2 → actproof-0.3.3}/tests/test_cli.py +0 -0
  34. {actproof-0.3.2 → actproof-0.3.3}/tests/test_manifest.py +0 -0
  35. {actproof-0.3.2 → actproof-0.3.3}/tests/test_receipt.py +0 -0
  36. {actproof-0.3.2 → actproof-0.3.3}/tests/test_signers_interface.py +0 -0
  37. {actproof-0.3.2 → actproof-0.3.3}/tests/test_signers_mnemonic.py +0 -0
  38. {actproof-0.3.2 → actproof-0.3.3}/tests/test_signers_v030_policy.py +0 -0
  39. {actproof-0.3.2 → actproof-0.3.3}/tests/test_timestamp.py +0 -0
  40. {actproof-0.3.2 → actproof-0.3.3}/tests/test_verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: actproof
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Verifiable receipts of regulated acts. Canonical JSON (RFC 8785), RFC 3161 trusted timestamps, Algorand ARC-2 anchoring, independent verification.
5
5
  Project-URL: Homepage, https://github.com/deyan-paroushev/actproof-py
6
6
  Project-URL: Documentation, https://github.com/deyan-paroushev/actproof-py/tree/main/docs
@@ -45,6 +45,8 @@ Requires-Dist: types-requests; extra == 'dev'
45
45
  Provides-Extra: docs
46
46
  Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
47
47
  Requires-Dist: mkdocs>=1.6; extra == 'docs'
48
+ Provides-Extra: events
49
+ Requires-Dist: actproof-events>=1.4.0rc1; extra == 'events'
48
50
  Provides-Extra: gcp
49
51
  Requires-Dist: google-cloud-kms<4.0,>=2.0; extra == 'gcp'
50
52
  Requires-Dist: google-crc32c<2.0,>=1.5; extra == 'gcp'
@@ -6,7 +6,7 @@ actproof: anchor signed JSON manifests; verify anyone's anchored receipts.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- __version__ = "0.3.2"
9
+ __version__ = "0.3.3"
10
10
 
11
11
 
12
12
  # ─────────────────────────────────────────────────────────────────
@@ -32,20 +32,27 @@ declares the corresponding blocks; absent blocks remain ``None``.
32
32
  Path resolution
33
33
  ---------------
34
34
 
35
- The catalogue is located on disk. How the bytes get there (git submodule,
36
- vendored copy, volume mount, fresh clone in CI) is a deployment decision the
37
- library does not constrain. Resolution order:
35
+ The catalogue is located on disk. How the bytes get there (pip-installed
36
+ ``actproof-events`` package, git submodule, vendored copy, volume mount, fresh
37
+ clone in CI) is a deployment decision the library does not constrain.
38
+ Resolution order:
38
39
 
39
40
  1. The ``acts_path`` argument to ``load_catalogue`` if provided.
40
41
  2. ``$ACTPROOF_CATALOGUE_PATH`` environment variable.
41
- 3. ``./actproof-events/catalogue/acts/`` relative to the current working dir.
42
- 4. ``./vendor/actproof-events/catalogue/acts/`` relative to the current working dir.
43
-
44
- The schema file is located by default relative to the acts path at
45
- ``../../spec/schemas/``. Resolution tries v3 first
46
- (``act_catalogue_entry.v3.json``), falls back to v2
47
- (``act_catalogue_entry.v2.json``). Whichever file is found is hashed into
48
- ``Catalogue.schema_hash`` for receipt binding.
42
+ 3. The installed ``actproof-events`` Python package, when importable. This
43
+ covers ``pip install actproof[events]`` and editable installs of the
44
+ events repo.
45
+ 4. ``./actproof-events/catalogue/acts/`` relative to the current working dir.
46
+ 5. ``./vendor/actproof-events/catalogue/acts/`` relative to the current working dir.
47
+
48
+ The schema file is located relative to the acts path. Two layouts are
49
+ tried: ``../../spec/schemas/`` (source-tree layout where ``catalogue/`` and
50
+ ``spec/`` are siblings at the repo root) and ``../../schemas/`` (installed
51
+ package layout where the wheel bundles ``catalogue/`` and ``schemas/``
52
+ side by side under ``actproof_events/data/``). Within each layout
53
+ resolution tries v3 first (``act_catalogue_entry.v3.json``), falls back to
54
+ v2 (``act_catalogue_entry.v2.json``). Whichever file is found is hashed
55
+ into ``Catalogue.schema_hash`` for receipt binding.
49
56
 
50
57
  What gets loaded
51
58
  ----------------
@@ -200,15 +207,25 @@ _FALLBACK_ACTS_PATHS: tuple[str, ...] = (
200
207
  )
201
208
  """Filesystem locations to try if no path is given and the env var is unset."""
202
209
 
203
- _SCHEMA_RELATIVE_PATH_V3: tuple[str, ...] = (
204
- "..", "..", "spec", "schemas", "act_catalogue_entry.v3.json",
210
+ _SCHEMA_RELATIVE_PATHS_V3: tuple[tuple[str, ...], ...] = (
211
+ ("..", "..", "spec", "schemas", "act_catalogue_entry.v3.json"),
212
+ ("..", "..", "schemas", "act_catalogue_entry.v3.json"),
205
213
  )
206
- """Default v3 schema path relative to the acts directory."""
207
-
208
- _SCHEMA_RELATIVE_PATH_V2: tuple[str, ...] = (
209
- "..", "..", "spec", "schemas", "act_catalogue_entry.v2.json",
214
+ """Default v3 schema paths relative to the acts directory. First tuple is the
215
+ source-tree layout (``catalogue/acts/`` sibling of ``spec/schemas/`` under the
216
+ repo root). Second tuple is the installed-package layout (``data/catalogue/acts/``
217
+ sibling of ``data/schemas/`` inside the bundled wheel)."""
218
+
219
+ _SCHEMA_RELATIVE_PATHS_V2: tuple[tuple[str, ...], ...] = (
220
+ ("..", "..", "spec", "schemas", "act_catalogue_entry.v2.json"),
221
+ ("..", "..", "schemas", "act_catalogue_entry.v2.json"),
210
222
  )
211
- """Default v2 schema path relative to the acts directory."""
223
+ """Default v2 schema paths relative to the acts directory, same two layouts."""
224
+
225
+ # Backward-compatible single-tuple aliases for any external consumer that
226
+ # imported the private names. Public API is via _resolve_schema_path().
227
+ _SCHEMA_RELATIVE_PATH_V3: tuple[str, ...] = _SCHEMA_RELATIVE_PATHS_V3[0]
228
+ _SCHEMA_RELATIVE_PATH_V2: tuple[str, ...] = _SCHEMA_RELATIVE_PATHS_V2[0]
212
229
 
213
230
 
214
231
  # ─────────────────────────────────────────────────────────────────
@@ -495,12 +512,27 @@ class Catalogue:
495
512
  git working tree or the caller did not provide it.
496
513
  schema_hash: ``"sha256:..."`` of the catalogue schema file bytes.
497
514
  Empty string if the schema file could not be located.
515
+ source_package_name: Optional Python distribution name of the
516
+ package that supplied the catalogue bytes. Populated
517
+ automatically by :func:`load_catalogue` when the catalogue is
518
+ resolved from an installed ``actproof-events`` package (i.e.
519
+ ``pip install actproof[events]``). ``None`` when the catalogue
520
+ is sourced via explicit path, environment variable, or
521
+ filesystem fallback. Callers building manifests can pass this
522
+ value through to ``build_manifest(catalogue_source_package_name=...)``
523
+ so the resulting receipt records pip-installable provenance
524
+ alongside the git-based pinning.
525
+ source_package_version: Optional version string of the source
526
+ package at load time (e.g. ``"1.4.0rc1"``). Set whenever
527
+ ``source_package_name`` is set; ``None`` otherwise.
498
528
  """
499
529
  entries: Mapping[str, CatalogueEntry]
500
530
  source_root: str
501
531
  source_uri: Optional[str]
502
532
  git_commit: Optional[str]
503
533
  schema_hash: str
534
+ source_package_name: Optional[str] = None
535
+ source_package_version: Optional[str] = None
504
536
 
505
537
  def get(self, act_type_id: str) -> Optional[CatalogueEntry]:
506
538
  """Look up an entry by ``act_type_id``. Returns ``None`` if absent."""
@@ -530,6 +562,14 @@ class ValidationIssue:
530
562
  what's in the loaded catalogue.
531
563
  - ``SCHEMA_HASH_MISMATCH``: manifest's schema_hash differs from
532
564
  what's in the loaded catalogue.
565
+ - ``SOURCE_PACKAGE_NAME_MISMATCH``: manifest pins one
566
+ installable package name (e.g. ``"actproof-events"``) and the
567
+ loaded catalogue was sourced from a differently-named
568
+ package. Only fires when both sides have set the optional
569
+ field.
570
+ - ``SOURCE_PACKAGE_VERSION_MISMATCH``: same as above for the
571
+ version string. Only fires when both sides have set the
572
+ optional field.
533
573
  - ``MISSING_REQUIRED_CLAIM_FIELD``: a required claim field is
534
574
  absent or empty in the manifest's claim.
535
575
  - ``MISSING_REQUIRED_EVIDENCE_LABEL``: a required evidence label
@@ -549,8 +589,53 @@ class ValidationIssue:
549
589
  # PATH RESOLUTION
550
590
  # ─────────────────────────────────────────────────────────────────
551
591
 
592
+ def _resolve_from_packaged_events() -> Optional[Path]:
593
+ """Try to resolve the acts path from the installed ``actproof-events`` package.
594
+
595
+ Returns the catalogue ``acts`` directory path if the package is importable
596
+ and exposes ``get_catalogue_path()`` (introduced in actproof-events
597
+ v1.4.0rc1) and that path resolves to an existing directory. Returns
598
+ ``None`` if the package is not installed, the function is absent (older
599
+ package version), or the bundled directory cannot be located on disk.
600
+
601
+ The returned value from ``get_catalogue_path()`` may be a :class:`Path`,
602
+ a :class:`str`, or any :class:`os.PathLike`; we coerce via ``Path(value)``
603
+ so future actproof-events releases that change their return type do not
604
+ silently break catalogue resolution.
605
+
606
+ This branch sits between the environment variable and the filesystem
607
+ fallbacks in :func:`_resolve_acts_path`'s priority order, so callers
608
+ using ``pip install actproof[events]`` get the bundled catalogue
609
+ automatically while explicit configuration (constructor arg or env var)
610
+ continues to win.
611
+ """
612
+ try:
613
+ import actproof_events # type: ignore[import-not-found]
614
+ except ImportError:
615
+ return None
616
+ get_path = getattr(actproof_events, "get_catalogue_path", None)
617
+ if get_path is None:
618
+ return None
619
+ try:
620
+ raw = get_path()
621
+ except Exception: # pragma: no cover - defensive against package bugs
622
+ return None
623
+ if raw is None:
624
+ return None
625
+ # Coerce to Path: accepts Path, str, and os.PathLike. TypeError when
626
+ # the return value is none of those (e.g. int) drops us back to None
627
+ # rather than propagating.
628
+ try:
629
+ path = Path(raw).expanduser().resolve()
630
+ except TypeError:
631
+ return None
632
+ if not path.is_dir():
633
+ return None
634
+ return path
635
+
636
+
552
637
  def _resolve_acts_path(explicit: Optional[Path]) -> Path:
553
- """Resolve the catalogue acts path from arg, env var, or fallbacks."""
638
+ """Resolve the catalogue acts path from arg, env var, installed package, or fallbacks."""
554
639
  if explicit is not None:
555
640
  path = explicit.expanduser().resolve()
556
641
  if not path.is_dir():
@@ -569,6 +654,10 @@ def _resolve_acts_path(explicit: Optional[Path]) -> Path:
569
654
  )
570
655
  return path
571
656
 
657
+ packaged = _resolve_from_packaged_events()
658
+ if packaged is not None:
659
+ return packaged
660
+
572
661
  for fallback in _FALLBACK_ACTS_PATHS:
573
662
  candidate = Path(fallback).resolve()
574
663
  if candidate.is_dir():
@@ -577,6 +666,7 @@ def _resolve_acts_path(explicit: Optional[Path]) -> Path:
577
666
  raise CatalogueLoadError(
578
667
  f"Could not locate the catalogue acts directory. "
579
668
  f"Pass acts_path explicitly, set {ENV_CATALOGUE_PATH}, "
669
+ f"install the actproof-events package (e.g. pip install actproof[events]), "
580
670
  f"or place the catalogue at one of: {', '.join(_FALLBACK_ACTS_PATHS)}."
581
671
  )
582
672
 
@@ -585,13 +675,19 @@ def _resolve_schema_path(acts_path: Path) -> Optional[Path]:
585
675
  """Find the schema file relative to the acts directory.
586
676
 
587
677
  Tries v3 first (``act_catalogue_entry.v3.json``), falls back to v2
588
- (``act_catalogue_entry.v2.json``). Returns ``None`` if neither file
589
- exists. Catalogues that ship v3 entries should have the v3 schema file
590
- present; catalogues that ship only v2 entries may have only the v2
591
- schema file. Whichever file is found is what gets hashed into
592
- ``Catalogue.schema_hash``.
678
+ (``act_catalogue_entry.v2.json``). For each version tries two layouts
679
+ in order: the source-tree layout (``../../spec/schemas/`` from acts,
680
+ where ``catalogue/`` and ``spec/`` are siblings at the repo root) and
681
+ the installed-package layout (``../../schemas/`` from acts, where the
682
+ wheel bundles ``catalogue/acts/`` and ``schemas/`` side by side under
683
+ ``actproof_events/data/``).
684
+
685
+ Returns ``None`` if no candidate file exists. Catalogues that ship v3
686
+ entries should have the v3 schema file present; catalogues that ship
687
+ only v2 entries may have only the v2 schema file. Whichever file is
688
+ found is what gets hashed into ``Catalogue.schema_hash``.
593
689
  """
594
- for relative_parts in (_SCHEMA_RELATIVE_PATH_V3, _SCHEMA_RELATIVE_PATH_V2):
690
+ for relative_parts in (*_SCHEMA_RELATIVE_PATHS_V3, *_SCHEMA_RELATIVE_PATHS_V2):
595
691
  candidate = acts_path.joinpath(*relative_parts).resolve()
596
692
  if candidate.is_file():
597
693
  return candidate
@@ -805,6 +901,13 @@ def load_catalogue(
805
901
 
806
902
  Returns:
807
903
  A ``Catalogue`` with all v2 and v3 entries indexed by ``act_type_id``.
904
+ When the catalogue was resolved from the installed
905
+ ``actproof-events`` package (priority 3 in the resolution order),
906
+ the returned ``Catalogue`` also carries ``source_package_name`` and
907
+ ``source_package_version`` set to that package's distribution name
908
+ and version. Callers can pass these to ``build_manifest`` so
909
+ receipts record pip-installable provenance alongside the existing
910
+ git-based catalogue pinning.
808
911
 
809
912
  Raises:
810
913
  CatalogueLoadError: If the path cannot be resolved or contains
@@ -826,6 +929,41 @@ def load_catalogue(
826
929
  # If we couldn't find the schema, schema_hash stays empty. Callers
827
930
  # populating CatalogueBinding will need to provide it some other way.
828
931
 
932
+ # Detect pip-installable provenance: if no explicit path was given and
933
+ # the resolved acts path came from the actproof-events Python package,
934
+ # record the package name and version so receipts can pin to
935
+ # ``pip install actproof-events==<version>`` alongside the git pinning.
936
+ # This is best-effort and never raises: receipts can still be built
937
+ # without these fields by callers who source the catalogue via git
938
+ # submodule, vendoring, or volume mount.
939
+ source_package_name: Optional[str] = None
940
+ source_package_version: Optional[str] = None
941
+ if acts_path is None:
942
+ packaged = _resolve_from_packaged_events()
943
+ if packaged is not None and packaged == resolved_acts:
944
+ # Read the version from the installed distribution metadata
945
+ # rather than the package's __version__ attribute. The
946
+ # distribution metadata is the authoritative source (set at
947
+ # install time from pyproject.toml or the wheel metadata);
948
+ # the __version__ attribute is convenience that may or may
949
+ # not be set, and can drift from the metadata if the package
950
+ # forgot to update one side. importlib.metadata is in the
951
+ # standard library from Python 3.8 onward.
952
+ from importlib.metadata import PackageNotFoundError, version
953
+ try:
954
+ source_package_version = version("actproof-events")
955
+ source_package_name = "actproof-events"
956
+ except PackageNotFoundError:
957
+ # The package files are present on disk (we resolved
958
+ # the path through _resolve_from_packaged_events) but
959
+ # the distribution metadata is missing. This is a
960
+ # corner case for editable installs that did not run
961
+ # ``pip install -e .`` correctly, or for namespace
962
+ # packages without distribution metadata. Fall through
963
+ # silently: the catalogue still loads, callers just
964
+ # get no package provenance.
965
+ pass
966
+
829
967
  logger.info(
830
968
  "Loaded %d catalogue entries from %s: %s",
831
969
  len(entries), resolved_acts, sorted(entries.keys()),
@@ -837,6 +975,8 @@ def load_catalogue(
837
975
  source_uri=source_uri,
838
976
  git_commit=git_commit,
839
977
  schema_hash=schema_hash,
978
+ source_package_name=source_package_name,
979
+ source_package_version=source_package_version,
840
980
  )
841
981
 
842
982
 
@@ -889,9 +1029,19 @@ def validate_manifest(
889
1029
  ) -> list[ValidationIssue]:
890
1030
  """Validate a manifest's claim and evidence against its catalogue entry.
891
1031
 
892
- Performs seven checks (see module docstring for the full list).
893
- Returns a list of issues; an empty list means the manifest is valid
894
- against the loaded catalogue.
1032
+ Performs up to nine checks. Seven always run; two additional ones
1033
+ (SOURCE_PACKAGE_NAME_MISMATCH, SOURCE_PACKAGE_VERSION_MISMATCH) only
1034
+ fire when both the manifest and the loaded catalogue carry the
1035
+ optional package-provenance fields. See the ``ValidationIssue``
1036
+ docstring for the full code list. Returns a list of issues; an
1037
+ empty list means the manifest is valid against the loaded catalogue.
1038
+
1039
+ The two package-provenance checks are diagnostic. The cryptographic
1040
+ binding between a manifest and the catalogue bytes it implements is
1041
+ ``entry_hash`` and ``schema_hash``; those checks already cover any
1042
+ byte-level drift. The package name and version checks give a
1043
+ clearer error message when the operator's environment has a
1044
+ different installed version than the issuer used at issue time.
895
1045
 
896
1046
  Catalogue conformance is one of several validation layers a complete
897
1047
  verifier runs. The others are: ``validate_manifest_shape`` (structural
@@ -967,6 +1117,57 @@ def validate_manifest(
967
1117
  )
968
1118
  )
969
1119
 
1120
+ # 4a. Source package name matches (only when both sides have set it)?
1121
+ # The optional source_package_name and source_package_version fields
1122
+ # are pip-installable-provenance metadata. They are NOT a substitute
1123
+ # for the cryptographic entry_hash and schema_hash checks above; the
1124
+ # bytes binding is still authoritative. These two checks give
1125
+ # operators a clearer diagnostic when a mismatch occurs ("you have
1126
+ # actproof-events 1.4.1 installed but the receipt was issued against
1127
+ # 1.4.0rc1") than the entry_hash mismatch alone would.
1128
+ if (
1129
+ manifest.catalogue.source_package_name
1130
+ and catalogue.source_package_name
1131
+ and manifest.catalogue.source_package_name != catalogue.source_package_name
1132
+ ):
1133
+ issues.append(
1134
+ ValidationIssue(
1135
+ code="SOURCE_PACKAGE_NAME_MISMATCH",
1136
+ message=(
1137
+ f"manifest pins source_package_name "
1138
+ f"{manifest.catalogue.source_package_name!r} but loaded "
1139
+ f"catalogue was sourced from {catalogue.source_package_name!r}. "
1140
+ f"This is package-provenance metadata, not a cryptographic "
1141
+ f"binding (entry_hash already covers the bytes), but indicates "
1142
+ f"the operator's environment differs from the issuer's."
1143
+ ),
1144
+ field="catalogue.source_package_name",
1145
+ )
1146
+ )
1147
+
1148
+ # 4b. Source package version matches (only when both sides have set it)?
1149
+ if (
1150
+ manifest.catalogue.source_package_version
1151
+ and catalogue.source_package_version
1152
+ and manifest.catalogue.source_package_version != catalogue.source_package_version
1153
+ ):
1154
+ issues.append(
1155
+ ValidationIssue(
1156
+ code="SOURCE_PACKAGE_VERSION_MISMATCH",
1157
+ message=(
1158
+ f"manifest pins source_package_version "
1159
+ f"{manifest.catalogue.source_package_version!r} but loaded "
1160
+ f"catalogue was sourced from version "
1161
+ f"{catalogue.source_package_version!r}. "
1162
+ f"This is package-provenance metadata, not a cryptographic "
1163
+ f"binding (entry_hash already covers the bytes), but signals "
1164
+ f"that ``pip install`` with the receipt's pinned version may "
1165
+ f"be required to reproduce byte-for-byte."
1166
+ ),
1167
+ field="catalogue.source_package_version",
1168
+ )
1169
+ )
1170
+
970
1171
  # 5. Required claim fields all present and non-empty?
971
1172
  for required_field in entry.required_claim_fields:
972
1173
  value = manifest.claim.get(required_field)
@@ -211,11 +211,22 @@ class ManifestValidationError(ValueError):
211
211
  class CatalogueBinding:
212
212
  """Pins which catalogue entry, version, and source revision this manifest implements.
213
213
 
214
- Together the five fields make the receipt independently verifiable:
214
+ Together the six required fields make the receipt independently verifiable:
215
215
  anyone can fetch the catalogue source at the pinned commit, locate the
216
216
  entry by ``act_type_id``, recompute the entry hash, and confirm that
217
217
  what was used matches what the receipt claims.
218
218
 
219
+ Two additional optional fields name the pip-installable Python package
220
+ that provides the catalogue bytes. They are populated automatically when
221
+ the catalogue is loaded from an installed ``actproof-events`` package
222
+ (via ``pip install actproof[events]``); they remain ``None`` when the
223
+ catalogue is sourced via git submodule, vendored copy, or volume mount.
224
+ The package fields are convenience provenance: a verifier with the
225
+ package name and version can ``pip install`` the exact release whose
226
+ bytes hash to ``entry_hash``, without having to clone the git repo
227
+ and check out the ``git_commit``. The cryptographic binding remains
228
+ ``entry_hash``; the package fields make the source easier to obtain.
229
+
219
230
  Attributes:
220
231
  act_type_id: The catalogue entry's act_type_id, e.g.
221
232
  ``"op:eu.nis2.art20.management_body_approval.v1"``.
@@ -224,6 +235,15 @@ class CatalogueBinding:
224
235
  git_commit: 40-character git SHA-1 commit of the catalogue at issue time.
225
236
  entry_hash: SHA-256 of the entry JSON file bytes, as ``"sha256:..."``.
226
237
  schema_hash: SHA-256 of the catalogue schema JSON bytes, as ``"sha256:..."``.
238
+ source_package_name: Optional Python distribution name of the package
239
+ that provided the catalogue bytes (typically ``"actproof-events"``).
240
+ ``None`` when the catalogue was sourced via git, vendoring, or
241
+ volume mount rather than a pip install.
242
+ source_package_version: Optional version string of the source package
243
+ at load time (e.g. ``"1.4.0rc1"``). Pairs with
244
+ ``source_package_name`` to allow a verifier to
245
+ ``pip install actproof-events==<version>`` and obtain byte-exact
246
+ catalogue bytes. ``None`` when ``source_package_name`` is ``None``.
227
247
  """
228
248
  act_type_id: str
229
249
  entry_version: int
@@ -231,6 +251,8 @@ class CatalogueBinding:
231
251
  git_commit: str
232
252
  entry_hash: str
233
253
  schema_hash: str
254
+ source_package_name: Optional[str] = None
255
+ source_package_version: Optional[str] = None
234
256
 
235
257
 
236
258
  @dataclass(frozen=True)
@@ -350,6 +372,8 @@ def build_manifest(
350
372
  issued_at: str,
351
373
  receipt_profile: str = RECEIPT_PROFILE_V1,
352
374
  batching_profile: str = BATCHING_PROFILE_SINGLE,
375
+ catalogue_source_package_name: Optional[str] = None,
376
+ catalogue_source_package_version: Optional[str] = None,
353
377
  ) -> Manifest:
354
378
  """Construct a Manifest from raw fields.
355
379
 
@@ -376,10 +400,46 @@ def build_manifest(
376
400
  issued_at: ISO 8601 UTC timestamp with ``Z`` suffix.
377
401
  receipt_profile: Receipt profile discriminator. Defaults to v1.
378
402
  batching_profile: Batching profile discriminator. Defaults to single.
403
+ catalogue_source_package_name: Optional pip distribution name of the
404
+ package that provided the catalogue bytes (typically
405
+ ``"actproof-events"``). Leave ``None`` when sourcing the catalogue
406
+ from a git submodule, vendored copy, or volume mount. Pass both
407
+ this and ``catalogue_source_package_version`` together, or
408
+ neither.
409
+ catalogue_source_package_version: Optional version string of the
410
+ source package at issue time (e.g. ``"1.4.0rc1"``).
379
411
 
380
412
  Returns:
381
413
  A fully populated, immutable ``Manifest`` instance.
414
+
415
+ Raises:
416
+ ManifestValidationError: If exactly one of
417
+ ``catalogue_source_package_name`` and
418
+ ``catalogue_source_package_version`` is provided. The two
419
+ are both-or-neither; either commit to package provenance
420
+ fully or omit it entirely.
382
421
  """
422
+ # Both-or-neither enforcement (mirrors manifest_from_dict). One-sided
423
+ # package provenance would silently round-trip to a different shape
424
+ # via manifest_to_dict, which is a correctness hazard. Reject up front.
425
+ if (catalogue_source_package_name is None) != (catalogue_source_package_version is None):
426
+ raise ManifestValidationError(
427
+ "catalogue_source_package_name and catalogue_source_package_version "
428
+ "must either both be provided or both be None. "
429
+ f"Got catalogue_source_package_name={catalogue_source_package_name!r}, "
430
+ f"catalogue_source_package_version={catalogue_source_package_version!r}."
431
+ )
432
+ if catalogue_source_package_name is not None and not isinstance(catalogue_source_package_name, str):
433
+ raise ManifestValidationError(
434
+ f"catalogue_source_package_name must be a string when present, "
435
+ f"got {type(catalogue_source_package_name).__name__}."
436
+ )
437
+ if catalogue_source_package_version is not None and not isinstance(catalogue_source_package_version, str):
438
+ raise ManifestValidationError(
439
+ f"catalogue_source_package_version must be a string when present, "
440
+ f"got {type(catalogue_source_package_version).__name__}."
441
+ )
442
+
383
443
  return Manifest(
384
444
  receipt_profile=receipt_profile,
385
445
  issued_at=issued_at,
@@ -390,6 +450,8 @@ def build_manifest(
390
450
  git_commit=catalogue_git_commit,
391
451
  entry_hash=catalogue_entry_hash,
392
452
  schema_hash=catalogue_schema_hash,
453
+ source_package_name=catalogue_source_package_name,
454
+ source_package_version=catalogue_source_package_version,
393
455
  ),
394
456
  issuer=Issuer(
395
457
  org_name=issuer_org_name,
@@ -413,23 +475,41 @@ def manifest_to_dict(m: Manifest) -> dict[str, Any]:
413
475
  The output is what gets passed to ``canonicalize()`` to produce the
414
476
  canonical bytes that are hashed and anchored.
415
477
 
478
+ Backwards compatibility: the optional catalogue fields
479
+ ``source_package_name`` and ``source_package_version`` are only
480
+ emitted when both are populated. When both are ``None``, they are
481
+ omitted entirely from the canonical output, so manifests built
482
+ before these fields existed produce byte-identical canonical bytes
483
+ (and therefore byte-identical hashes) as they did before.
484
+
416
485
  Args:
417
486
  m: The Manifest to serialise.
418
487
 
419
488
  Returns:
420
489
  A nested dict with all manifest fields.
421
490
  """
491
+ catalogue_block: dict[str, Any] = {
492
+ "act_type_id": m.catalogue.act_type_id,
493
+ "entry_version": m.catalogue.entry_version,
494
+ "source_uri": m.catalogue.source_uri,
495
+ "git_commit": m.catalogue.git_commit,
496
+ "entry_hash": m.catalogue.entry_hash,
497
+ "schema_hash": m.catalogue.schema_hash,
498
+ }
499
+ # Emit the optional package fields only when both are populated. This
500
+ # preserves byte-exact canonical bytes for manifests issued before
501
+ # these fields existed (where both are None).
502
+ if (
503
+ m.catalogue.source_package_name is not None
504
+ and m.catalogue.source_package_version is not None
505
+ ):
506
+ catalogue_block["source_package_name"] = m.catalogue.source_package_name
507
+ catalogue_block["source_package_version"] = m.catalogue.source_package_version
508
+
422
509
  return {
423
510
  "receipt_profile": m.receipt_profile,
424
511
  "issued_at": m.issued_at,
425
- "catalogue": {
426
- "act_type_id": m.catalogue.act_type_id,
427
- "entry_version": m.catalogue.entry_version,
428
- "source_uri": m.catalogue.source_uri,
429
- "git_commit": m.catalogue.git_commit,
430
- "entry_hash": m.catalogue.entry_hash,
431
- "schema_hash": m.catalogue.schema_hash,
432
- },
512
+ "catalogue": catalogue_block,
433
513
  "issuer": {
434
514
  "org_name": m.issuer.org_name,
435
515
  "authority_label": m.issuer.authority_label,
@@ -465,6 +545,20 @@ def manifest_from_dict(d: Mapping[str, Any]) -> Manifest:
465
545
  manifest as a nested dict and needs to reconstruct the typed object
466
546
  for inspection.
467
547
 
548
+ Backwards compatibility: the optional catalogue fields
549
+ ``source_package_name`` and ``source_package_version`` are read via
550
+ ``.get(...)`` so receipts predating these fields parse without
551
+ raising. Receipts that carry them populate the corresponding
552
+ ``CatalogueBinding`` attributes.
553
+
554
+ The two package fields are both-or-neither: a manifest that carries
555
+ one of them but not the other is malformed and parsing raises
556
+ ``ManifestValidationError``. This prevents a roundtrip discrepancy
557
+ where the input dict had one field, the parsed object stores it
558
+ silently, and re-serialisation via ``manifest_to_dict`` drops both
559
+ (because ``manifest_to_dict`` emits both only when both are set).
560
+ Either commit to package provenance fully or omit it entirely.
561
+
468
562
  Args:
469
563
  d: A dict matching the canonical manifest shape.
470
564
 
@@ -473,12 +567,36 @@ def manifest_from_dict(d: Mapping[str, Any]) -> Manifest:
473
567
 
474
568
  Raises:
475
569
  ManifestValidationError: If a required field is missing or has
476
- the wrong type.
570
+ the wrong type, or if exactly one of the optional package
571
+ fields is present.
477
572
  """
478
573
  try:
479
574
  catalogue_dict = d["catalogue"]
480
575
  issuer_dict = d["issuer"]
481
576
 
577
+ # Both-or-neither enforcement for the optional package fields.
578
+ # Catches malformed manifests with one field set and the other
579
+ # absent or None, which would lose information on roundtrip.
580
+ source_package_name = catalogue_dict.get("source_package_name")
581
+ source_package_version = catalogue_dict.get("source_package_version")
582
+ if (source_package_name is None) != (source_package_version is None):
583
+ raise ManifestValidationError(
584
+ "catalogue.source_package_name and catalogue.source_package_version "
585
+ "must either both be present or both be omitted. "
586
+ f"Got source_package_name={source_package_name!r}, "
587
+ f"source_package_version={source_package_version!r}."
588
+ )
589
+ if source_package_name is not None and not isinstance(source_package_name, str):
590
+ raise ManifestValidationError(
591
+ f"catalogue.source_package_name must be a string when present, "
592
+ f"got {type(source_package_name).__name__}."
593
+ )
594
+ if source_package_version is not None and not isinstance(source_package_version, str):
595
+ raise ManifestValidationError(
596
+ f"catalogue.source_package_version must be a string when present, "
597
+ f"got {type(source_package_version).__name__}."
598
+ )
599
+
482
600
  return Manifest(
483
601
  receipt_profile=d["receipt_profile"],
484
602
  issued_at=d["issued_at"],
@@ -489,6 +607,8 @@ def manifest_from_dict(d: Mapping[str, Any]) -> Manifest:
489
607
  git_commit=catalogue_dict["git_commit"],
490
608
  entry_hash=catalogue_dict["entry_hash"],
491
609
  schema_hash=catalogue_dict["schema_hash"],
610
+ source_package_name=source_package_name,
611
+ source_package_version=source_package_version,
492
612
  ),
493
613
  issuer=Issuer(
494
614
  org_name=issuer_dict["org_name"],
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "actproof"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Verifiable receipts of regulated acts. Canonical JSON (RFC 8785), RFC 3161 trusted timestamps, Algorand ARC-2 anchoring, independent verification."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -62,6 +62,8 @@ gcp = [
62
62
  "google-crc32c>=1.5,<2.0",
63
63
  ]
64
64
 
65
+ events = ["actproof-events>=1.4.0rc1"]
66
+
65
67
  [project.urls]
66
68
  Homepage = "https://github.com/deyan-paroushev/actproof-py"
67
69
  Documentation = "https://github.com/deyan-paroushev/actproof-py/tree/main/docs"