proofbundle 3.1.2__tar.gz → 3.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 (152) hide show
  1. {proofbundle-3.1.2/src/proofbundle.egg-info → proofbundle-3.1.3}/PKG-INFO +6 -4
  2. {proofbundle-3.1.2 → proofbundle-3.1.3}/README.md +5 -3
  3. {proofbundle-3.1.2 → proofbundle-3.1.3}/pyproject.toml +1 -1
  4. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/__init__.py +2 -2
  5. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/bundle.py +56 -7
  6. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/cli.py +188 -13
  7. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/decision.py +5 -1
  8. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/policies/decision-receipt-template-v1.json +1 -0
  9. proofbundle-3.1.3/src/proofbundle/policies/research-preview-v1.json +16 -0
  10. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/policies/strict-eval-authenticated-root-template-v1.json +1 -0
  11. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/policies/strict-eval-template-v1.json +1 -0
  12. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/policies/strict-prereg-template-v1.json +1 -0
  13. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/policy.py +302 -18
  14. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/policy_profiles.py +30 -6
  15. {proofbundle-3.1.2 → proofbundle-3.1.3/src/proofbundle.egg-info}/PKG-INFO +6 -4
  16. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle.egg-info/SOURCES.txt +3 -0
  17. proofbundle-3.1.3/tests/test_lens_review_fixes_3_1_3.py +252 -0
  18. proofbundle-3.1.3/tests/test_policy_lifecycle_purpose.py +464 -0
  19. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_root_authenticity.py +44 -13
  20. proofbundle-3.1.3/tests/test_tree_context_authenticity.py +329 -0
  21. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_trust_policy.py +4 -2
  22. proofbundle-3.1.2/src/proofbundle/policies/research-preview-v1.json +0 -7
  23. {proofbundle-3.1.2 → proofbundle-3.1.3}/LICENSE +0 -0
  24. {proofbundle-3.1.2 → proofbundle-3.1.3}/setup.cfg +0 -0
  25. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/_inspect_registry.py +0 -0
  26. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/_integration.py +0 -0
  27. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/_strict_json.py +0 -0
  28. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/adapters/__init__.py +0 -0
  29. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/adapters/_provenance.py +0 -0
  30. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/adapters/eee.py +0 -0
  31. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/adapters/inspect_ai.py +0 -0
  32. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/adapters/lm_eval.py +0 -0
  33. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/adapters/promptfoo.py +0 -0
  34. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/adapters/samples.py +0 -0
  35. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/anchors.py +0 -0
  36. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/anchors_chia.py +0 -0
  37. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/anchors_chia_add.py +0 -0
  38. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/anchors_markovian.py +0 -0
  39. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/anchors_ots.py +0 -0
  40. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/anchors_rfc3161.py +0 -0
  41. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/beacon.py +0 -0
  42. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/canonical.py +0 -0
  43. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/checkpoint.py +0 -0
  44. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/demo.py +0 -0
  45. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/dsse.py +0 -0
  46. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/eee_eval_schema.json +0 -0
  47. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/emit.py +0 -0
  48. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/errors.py +0 -0
  49. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/evalclaim.py +0 -0
  50. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/experimental/__init__.py +0 -0
  51. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/experimental/enclave.py +0 -0
  52. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/hf_evals.py +0 -0
  53. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/inspect_hook.py +0 -0
  54. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/intoto.py +0 -0
  55. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/kbjwt.py +0 -0
  56. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/merkle.py +0 -0
  57. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/persample.py +0 -0
  58. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/prereg.py +0 -0
  59. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/py.typed +0 -0
  60. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/pytest_plugin.py +0 -0
  61. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/sdjwt.py +0 -0
  62. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/sdjwt_issue.py +0 -0
  63. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/signature.py +0 -0
  64. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/statuslist.py +0 -0
  65. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle/tlogproof.py +0 -0
  66. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  67. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle.egg-info/entry_points.txt +0 -0
  68. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle.egg-info/requires.txt +0 -0
  69. {proofbundle-3.1.2 → proofbundle-3.1.3}/src/proofbundle.egg-info/top_level.txt +0 -0
  70. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_adapters.py +0 -0
  71. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_adversarial.py +0 -0
  72. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_anchor_target_trustedtime.py +0 -0
  73. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_anchors_chia.py +0 -0
  74. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_anchors_chia_add.py +0 -0
  75. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_anchors_chia_claims.py +0 -0
  76. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_anchors_generic.py +0 -0
  77. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_anchors_markovian.py +0 -0
  78. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_anchors_ots.py +0 -0
  79. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_anchors_rfc3161.py +0 -0
  80. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_beacon.py +0 -0
  81. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_branch_base_check.py +0 -0
  82. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_bundle.py +0 -0
  83. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_bundle_robustness.py +0 -0
  84. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_canonical.py +0 -0
  85. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_checkpoint.py +0 -0
  86. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_claims_hygiene.py +0 -0
  87. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_cli.py +0 -0
  88. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_cli_eval.py +0 -0
  89. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_cli_require_anchor.py +0 -0
  90. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_conformance.py +0 -0
  91. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_cosignature.py +0 -0
  92. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_cosignature_mldsa.py +0 -0
  93. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_decision_anchors.py +0 -0
  94. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_decision_cli.py +0 -0
  95. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_decision_evidence.py +0 -0
  96. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_decision_fuzz.py +0 -0
  97. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_decision_hardening.py +0 -0
  98. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_decision_policy.py +0 -0
  99. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_decision_schema.py +0 -0
  100. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_decision_validator_api.py +0 -0
  101. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_decision_verify.py +0 -0
  102. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_demo.py +0 -0
  103. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_docs_truth.py +0 -0
  104. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_dup_key_reject.py +0 -0
  105. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_ed25519_semantics.py +0 -0
  106. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_eee.py +0 -0
  107. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_emit.py +0 -0
  108. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_eval_claim_schema.py +0 -0
  109. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_eval_evidence_class.py +0 -0
  110. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_evalclaim.py +0 -0
  111. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_examples.py +0 -0
  112. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_experimental_enclave.py +0 -0
  113. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_fork_pr_secret_isolation.py +0 -0
  114. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_fuzz_parsers.py +0 -0
  115. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_hf_entry_verify_eee_digest.py +0 -0
  116. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_hf_evals.py +0 -0
  117. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_inspect_hook.py +0 -0
  118. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_intoto.py +0 -0
  119. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_intoto_claims_hygiene.py +0 -0
  120. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_intoto_content_root_migration.py +0 -0
  121. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_intoto_dsse.py +0 -0
  122. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_intoto_eval_result.py +0 -0
  123. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_intoto_examples.py +0 -0
  124. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_intoto_spec_diff.py +0 -0
  125. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_intoto_svr.py +0 -0
  126. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_kbjwt.py +0 -0
  127. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_merkle.py +0 -0
  128. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_merkle_property.py +0 -0
  129. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_ok_semantics.py +0 -0
  130. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_oversized_int_guard.py +0 -0
  131. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_persample.py +0 -0
  132. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_pinned_key_validation.py +0 -0
  133. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_policy_explain_lint.py +0 -0
  134. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_policy_profiles.py +0 -0
  135. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_policy_templates.py +0 -0
  136. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_predicate_type_enforcement.py +0 -0
  137. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_prereg.py +0 -0
  138. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_promptfoo.py +0 -0
  139. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_provenance.py +0 -0
  140. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_pytest_plugin.py +0 -0
  141. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_rekor_interop.py +0 -0
  142. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_release_integrity_gate.py +0 -0
  143. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_rfc6962_external_vectors.py +0 -0
  144. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_schema.py +0 -0
  145. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_sdjwt_duplicate_cnf.py +0 -0
  146. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_sdjwt_issue.py +0 -0
  147. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_sdjwt_reference.py +0 -0
  148. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_sdjwt_verify_binding.py +0 -0
  149. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_signature.py +0 -0
  150. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_statuslist.py +0 -0
  151. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_tlogproof.py +0 -0
  152. {proofbundle-3.1.2 → proofbundle-3.1.3}/tests/test_verify_matrix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 3.1.2
3
+ Version: 3.1.3
4
4
  Summary: Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT.
5
5
  Author: Konrad Gruszka
6
6
  License: MIT
@@ -127,9 +127,11 @@ proofbundle verify receipt.json --policy trust_policy.json # POLICY: OK | FAIL
127
127
  # start from a shipped TEMPLATE and pin your own signer — offline, no network:
128
128
  proofbundle policy instantiate strict-eval-template-v1 \
129
129
  --issuer-key org-eval.pub --policy-id org/strict-eval-v1 --output org.json
130
- proofbundle verify receipt.json --json --policy org.json --expected-root <b64>
131
- # root_authenticity.safeForAutomation: true only when the policy pins a trusted signer AND the
132
- # root is authenticated (else false, with automationBlockers naming every reason)
130
+ proofbundle verify receipt.json --json --policy org.json \
131
+ --expected-root <b64> --expected-tree-size <n> # or: --trusted-checkpoint note.txt --checkpoint-vkey <vkey>
132
+ # root_authenticity.safeForAutomation: true only when the policy pins a trusted signer AND root
133
+ # plus tree size are authenticated ATOMICALLY from one source (a root-bytes-only pin is
134
+ # rootTrustLevel: ROOT_BYTES_ONLY — never automation-safe; automationBlockers names every reason)
133
135
  ```
134
136
 
135
137
  ## Inspect-native? (METR Task Standard, UK-AISI ecosystem)
@@ -73,9 +73,11 @@ proofbundle verify receipt.json --policy trust_policy.json # POLICY: OK | FAIL
73
73
  # start from a shipped TEMPLATE and pin your own signer — offline, no network:
74
74
  proofbundle policy instantiate strict-eval-template-v1 \
75
75
  --issuer-key org-eval.pub --policy-id org/strict-eval-v1 --output org.json
76
- proofbundle verify receipt.json --json --policy org.json --expected-root <b64>
77
- # root_authenticity.safeForAutomation: true only when the policy pins a trusted signer AND the
78
- # root is authenticated (else false, with automationBlockers naming every reason)
76
+ proofbundle verify receipt.json --json --policy org.json \
77
+ --expected-root <b64> --expected-tree-size <n> # or: --trusted-checkpoint note.txt --checkpoint-vkey <vkey>
78
+ # root_authenticity.safeForAutomation: true only when the policy pins a trusted signer AND root
79
+ # plus tree size are authenticated ATOMICALLY from one source (a root-bytes-only pin is
80
+ # rootTrustLevel: ROOT_BYTES_ONLY — never automation-safe; automationBlockers names every reason)
79
81
  ```
80
82
 
81
83
  ## Inspect-native? (METR Task Standard, UK-AISI ecosystem)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "3.1.2"
7
+ version = "3.1.3"
8
8
  description = "Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -13,12 +13,12 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "3.1.2"
16
+ __version__ = "3.1.3"
17
17
 
18
18
  # The `proofbundle/v0.1` normative spec revision this build implements — kept in sync with the
19
19
  # `Revision:` line at the top of SPEC.md by tests/test_docs_truth.py (WP-B1, closes #28). Bump
20
20
  # both together whenever SPEC.md's normative text changes (not on every package release).
21
- SPEC_REVISION = "2026-07-11"
21
+ SPEC_REVISION = "2026-07-13"
22
22
 
23
23
  __all__ = [
24
24
  "__version__",
@@ -43,12 +43,18 @@ AUTOMATION_BLOCKER_REASONS = {
43
43
  "CRYPTO_FAILED": "Cryptographic verification did not pass",
44
44
  "ROOT_NOT_AUTHENTICATED": "The Merkle root was not authenticated against a relying-party value "
45
45
  "(--expected-root or a policy trusted_roots entry)",
46
+ "TREE_CONTEXT_NOT_AUTHENTICATED": "Root and tree size were not authenticated ATOMICALLY from one "
47
+ "source (a signed checkpoint via --trusted-checkpoint / policy "
48
+ "trusted_checkpoints, or an --expected-root + "
49
+ "--expected-tree-size pair) — a root-bytes-only pin cannot "
50
+ "detect a tree-size/leaf-index relabel (A-P0-1)",
46
51
  "POLICY_NOT_EVALUATED": "No trust policy was evaluated (supply --policy to authorise a signer)",
47
52
  "POLICY_FAILED": "The supplied trust policy was not satisfied",
48
53
  "SIGNER_NOT_PINNED": "The trust policy pins no trusted signer identity (attributes to nobody)",
49
54
  "TEMPLATE_NOT_INSTANTIATED": "The trust policy is a raw template (requiresIdentityOverlay:true) — "
50
55
  "instantiate it with a signer identity before depending on it for automation",
51
56
  "POLICY_EXPIRED": "The trust policy has expired (its valid_until is in the past)",
57
+ "POLICY_NOT_YET_VALID": "The trust policy is not yet valid (its valid_from is in the future)",
52
58
  "POLICY_WARNINGS_PRESENT": "The trust policy carries a warning that blocks automation",
53
59
  "ANCHOR_REQUIRED_FAILED": "A required external time anchor did not verify",
54
60
  "PUBLIC_TRANSPARENCY_REQUIRED_FAILED": "A required public-transparency proof did not verify",
@@ -433,9 +439,12 @@ def root_authenticity_summary(result: VerificationResult, *,
433
439
  signer_trusted: Optional[bool] = None,
434
440
  policy_warnings: Optional[list] = None,
435
441
  policy_expired: Optional[bool] = None,
442
+ policy_not_yet_valid: Optional[bool] = None,
436
443
  requires_identity_overlay: Optional[bool] = None,
437
444
  public_transparency_ok: Optional[bool] = None,
438
- replay_ok: Optional[bool] = None) -> dict:
445
+ replay_ok: Optional[bool] = None,
446
+ tree_context_authenticated: Optional[bool] = None,
447
+ checkpoint_authenticity: Optional[str] = None) -> dict:
439
448
  """Structured root-authenticity verdicts (P0-A §6.3), derived from a completed VerificationResult.
440
449
 
441
450
  Separates what Merkle inclusion actually proves from what it does NOT, as three-state strings so a
@@ -473,6 +482,28 @@ def root_authenticity_summary(result: VerificationResult, *,
473
482
  root_auth = "FAIL"
474
483
  else:
475
484
  root_auth = "NOT_EVALUATED"
485
+
486
+ # A-P0-1 §5.3: the differentiated tree-context verdicts. `root_auth` above is the root-BYTES
487
+ # verdict; TREE_CONTEXT_AUTHENTICITY additionally requires that root and tree_size were
488
+ # authenticated ATOMICALLY from one source (a signed checkpoint, a policy trusted_checkpoints
489
+ # match, or an RP-supplied root+size PAIR). A naked root pin reaches at most
490
+ # ROOT_BYTES_AUTHENTICITY: PASS — never TREE_CONTEXT_AUTHENTICITY: PASS (§5.5).
491
+ if tree_context_authenticated is True:
492
+ tree_context = "PASS"
493
+ elif tree_context_authenticated is False:
494
+ tree_context = "FAIL"
495
+ else:
496
+ tree_context = "NOT_EVALUATED"
497
+ cp_auth = checkpoint_authenticity if checkpoint_authenticity in ("PASS", "FAIL") \
498
+ else "NOT_EVALUATED"
499
+ if tree_context == "PASS" and cp_auth == "PASS":
500
+ root_trust_level = "CHECKPOINT"
501
+ elif tree_context == "PASS":
502
+ root_trust_level = "ROOT_AND_TREE_SIZE_PINNED"
503
+ elif root_auth == "PASS":
504
+ root_trust_level = "ROOT_BYTES_ONLY"
505
+ else:
506
+ root_trust_level = "NONE"
476
507
  # P0-B (audit 2026-07-13): the former `policy_ok is not False` let policy_ok=None (no policy
477
508
  # evaluated) through → a crypto-valid, root-pinned receipt looked automation-safe though NO trusted
478
509
  # signer was ever authorised. safeForAutomation is now a GLOBAL trust verdict: policy_ok must be True
@@ -492,24 +523,35 @@ def root_authenticity_summary(result: VerificationResult, *,
492
523
  blockers.append("CRYPTO_FAILED")
493
524
  if root_auth != "PASS":
494
525
  blockers.append("ROOT_NOT_AUTHENTICATED")
526
+ if tree_context != "PASS":
527
+ # A-P0-1 §5.4: safeForAutomation requires the ATOMIC (root, tree_size) authentication —
528
+ # root bytes alone cannot detect the 2→3-leaf relabel, so they never authorise automation.
529
+ blockers.append("TREE_CONTEXT_NOT_AUTHENTICATED")
495
530
  if policy_ok is None:
496
531
  blockers.append("POLICY_NOT_EVALUATED")
497
532
  elif policy_ok is False:
498
533
  blockers.append("POLICY_FAILED")
499
- elif requires_identity_overlay:
534
+ elif signer_trusted is not True and not requires_identity_overlay:
535
+ blockers.append("SIGNER_NOT_PINNED") # policy passed but pins no trusted identity (attributes to nobody)
536
+ elif signer_trusted is True and policy_warnings:
537
+ blockers.append("POLICY_WARNINGS_PRESENT") # signer pinned yet the policy still warns (forward-compat)
538
+ if requires_identity_overlay:
500
539
  # AP-2 §6.2 (L2 pre-land audit): a RAW template (requiresIdentityOverlay:true) is never automation-safe
501
540
  # — reported as its OWN blocker, not SIGNER_NOT_PINNED, which would be factually wrong when the template
502
- # actually does match a signer (the real reason is the un-cleared template-lifecycle flag).
541
+ # actually does match a signer (the real reason is the un-cleared template-lifecycle flag). Independent
542
+ # of the policy_ok chain since A-P0-2: the eval path now FAILS a raw template (policy:not_template →
543
+ # POLICY_FAILED), and the honest template blocker must still be reported alongside it.
503
544
  blockers.append("TEMPLATE_NOT_INSTANTIATED")
504
- elif signer_trusted is not True:
505
- blockers.append("SIGNER_NOT_PINNED") # policy passed but pins no trusted identity (attributes to nobody)
506
- elif policy_warnings:
507
- blockers.append("POLICY_WARNINGS_PRESENT") # signer pinned yet the policy still warns (forward-compat)
508
545
  # AP-2 §6.4 lifecycle: an EXPIRED policy is unsafe to automate on even if it otherwise passed (a stale
509
546
  # signer pin the relying party has since rotated away from). Independent of the signer/warning chain so
510
547
  # it is reported alongside, never in place of, another reason.
511
548
  if policy_expired is True:
512
549
  blockers.append("POLICY_EXPIRED")
550
+ # A-P0-2 not-before mirror (Lens-2/3/4/6 review): a policy whose valid_from is in the FUTURE at the
551
+ # real current time is not in force, so a crypto-valid receipt under it is not automation-safe even
552
+ # when historically verified — the symmetric case to POLICY_EXPIRED. Reported at current time.
553
+ if policy_not_yet_valid is True:
554
+ blockers.append("POLICY_NOT_YET_VALID")
513
555
  if anchor_ok is False:
514
556
  blockers.append("ANCHOR_REQUIRED_FAILED")
515
557
  if public_transparency_ok is False:
@@ -519,7 +561,14 @@ def root_authenticity_summary(result: VerificationResult, *,
519
561
  return {
520
562
  "payloadSignature": _tri("ed25519-signature"),
521
563
  "merkleConsistency": _tri("merkle-inclusion"),
564
+ # A-P0-1 §5.3: differentiated statuses. `rootAuthenticity` is kept as a WIRE-COMPAT ALIAS of
565
+ # the root-BYTES verdict (it never asserted more than bytes); the undifferentiated reading
566
+ # of it as full root trust is retired — automation keys off treeContextAuthenticity.
522
567
  "rootAuthenticity": root_auth,
568
+ "rootBytesAuthenticity": root_auth,
569
+ "treeContextAuthenticity": tree_context,
570
+ "checkpointAuthenticity": cp_auth,
571
+ "rootTrustLevel": root_trust_level,
523
572
  "publicTransparency": "NOT_EVALUATED",
524
573
  "safeForAutomation": not blockers,
525
574
  "automationBlockers": blockers,
@@ -458,6 +458,65 @@ def _cmd_verify(args: argparse.Namespace) -> int:
458
458
  # --trusted-tsa-root / --bitcoin-header is a clean exit 2 (not a raw traceback), like every other
459
459
  # malformed flag. Built once, used at the anchor-requirement site (which is outside this try).
460
460
  rp_trust_material = _build_rp_trust(args)
461
+ # A-P0-2 §6.3: historical verification only via an EXPLICIT instant — never silent backdating.
462
+ verification_time = None
463
+ if getattr(args, "verification_time", None) is not None:
464
+ from .policy import _parse_iso_utc # noqa: PLC0415
465
+ if not getattr(args, "policy", None):
466
+ raise ValueError("--verification-time only applies together with --policy (it sets "
467
+ "the instant the policy lifecycle is evaluated at)")
468
+ verification_time = _parse_iso_utc(args.verification_time)
469
+ if verification_time is None:
470
+ raise ValueError(f"--verification-time {args.verification_time!r} is not an "
471
+ "ISO-8601 timestamp (e.g. 2026-01-01T00:00:00Z)")
472
+ # It is HISTORICAL: a FUTURE instant is not a historical query and would let a policy that
473
+ # is not-yet-valid TODAY be forward-dated into force (Lens-2/3/4/6 review). Reject it — the
474
+ # honest present-tense verdict for a not-yet-valid policy is exit 3 in current mode.
475
+ from datetime import datetime, timezone # noqa: PLC0415
476
+ if verification_time >= datetime.now(timezone.utc):
477
+ raise ValueError("--verification-time must be in the past — it evaluates the policy AS "
478
+ "OF a historical instant; a future instant is not a historical query")
479
+ # A-P0-1: a signed C2SP checkpoint is the ONE authenticated source of BOTH the expected root
480
+ # AND the expected tree size (the atomic tree context). Both flags belong together; a note
481
+ # that parses but does not verify is a verification failure (exit 1), a malformed note is
482
+ # malformed input (exit 2), and an explicit flag conflicting with the checkpoint is ambiguous.
483
+ cp_supplied = getattr(args, "trusted_checkpoint", None) is not None
484
+ cp_vkey = getattr(args, "checkpoint_vkey", None)
485
+ if cp_supplied != (cp_vkey is not None):
486
+ raise ValueError("--trusted-checkpoint and --checkpoint-vkey belong together (the "
487
+ "signed note and the key that authenticates it)")
488
+ cp_ok = None
489
+ cp_detail = ""
490
+ expected_root = getattr(args, "expected_root", None)
491
+ expected_tree_size = getattr(args, "expected_tree_size", None)
492
+ if cp_supplied:
493
+ assert cp_vkey is not None # the guard above ties cp_supplied ⇔ cp_vkey present (mypy narrowing)
494
+ import base64 as _b64mod # noqa: PLC0415
495
+ from .checkpoint import verify_checkpoint # noqa: PLC0415
496
+ with open(args.trusted_checkpoint, encoding="utf-8") as handle:
497
+ note = handle.read()
498
+ cp_res = verify_checkpoint(note, cp_vkey) # malformed note/vkey → BundleFormatError → exit 2
499
+ cp_ok = bool(cp_res["ok"])
500
+ if cp_ok:
501
+ cp_root_b64 = _b64mod.b64encode(cp_res["root"]).decode("ascii")
502
+ if expected_root is not None:
503
+ try:
504
+ explicit_root = _b64mod.b64decode(expected_root, validate=True)
505
+ except (ValueError, TypeError) as exc:
506
+ raise ValueError("--expected-root is not valid base64") from exc
507
+ if explicit_root != cp_res["root"]:
508
+ raise ValueError("--expected-root conflicts with the trusted checkpoint's "
509
+ "root — ambiguous; supply only one, or make them equal")
510
+ if expected_tree_size is not None and expected_tree_size != cp_res["tree_size"]:
511
+ raise ValueError("--expected-tree-size conflicts with the trusted checkpoint's "
512
+ "tree size — ambiguous; supply only one, or make them equal")
513
+ expected_root = cp_root_b64
514
+ expected_tree_size = cp_res["tree_size"]
515
+ cp_detail = (f"checkpoint origin {cp_res['origin']!r} authenticates "
516
+ f"(root, tree_size={cp_res['tree_size']}) atomically")
517
+ else:
518
+ cp_detail = ("checkpoint signature does not verify under the supplied vkey — the "
519
+ "expected root/tree size could not be authenticated (fail-closed)")
461
520
  # Resolve the path to a dict ONCE and pass it to both verify_bundle and recompute — a second per-function
462
521
  # re-read of the same path reopens a TOCTOU window (release-review consistency fix, mirrors show-eval).
463
522
  bundle = load_bundle(args.bundle)
@@ -502,8 +561,11 @@ def _cmd_verify(args: argparse.Namespace) -> int:
502
561
  merged.update(rp_trust_material or {}) # CLI flags take precedence on the same key
503
562
  rp_trust_material = merged
504
563
  result = verify_bundle(bundle, expected_aud=effective_aud, expected_nonce=flag_nonce,
505
- expected_root_b64=getattr(args, "expected_root", None),
506
- expected_tree_size=getattr(args, "expected_tree_size", None))
564
+ expected_root_b64=expected_root,
565
+ expected_tree_size=expected_tree_size)
566
+ if cp_supplied:
567
+ # a real verification step: a non-verifying checkpoint fails the crypto verdict (exit 1).
568
+ result.add("checkpoint-authenticity", bool(cp_ok), cp_detail)
507
569
  roots = recompute_merkle_root_b64(bundle) if args.verbose else None
508
570
  except (ProofBundleError, OSError, ValueError, RecursionError) as exc: # file/JSON/format/policy errors → clean exit 2, never a raw traceback
509
571
  # RecursionError: deeply-nested JSON overflows json.load's recursion; catch it here too so it
@@ -522,10 +584,19 @@ def _cmd_verify(args: argparse.Namespace) -> int:
522
584
  # never a reason to trust bytes whose signature/merkle failed (fail-closed): policy_ok stays None
523
585
  # (NOT_EVALUATED) and exit 1 dominates.
524
586
  policy_ok = None
525
- policy_result = None
587
+ policy_result = None # HISTORICAL evaluation (now=verification_time): drives the exit code + POLICY line
588
+ policy_result_now = None # CURRENT evaluation (now=None): drives safeForAutomation + the tree-context inputs
526
589
  if policy is not None and crypto_ok:
527
- policy_result = evaluate_policy(bundle, result, policy)
590
+ # A-P0-2 §6.3: in historical mode `now` is the explicit --verification-time — the POLICY verdict +
591
+ # exit code answer "was it valid THEN". But safeForAutomation is a PRESENT-tense "safe to act on now"
592
+ # verdict, so its inputs (lifecycle, tree-context, checkpoint validity) MUST use the real current time
593
+ # (Lens-2/3/4/6 review: a not-yet-valid policy or an expired-today checkpoint must never read
594
+ # automation-safe just because a past instant was supplied). We therefore evaluate TWICE in historical
595
+ # mode; in current mode the two coincide (one evaluation, no behaviour change).
596
+ policy_result = evaluate_policy(bundle, result, policy, now=verification_time)
528
597
  policy_ok = policy_result["policy_ok"]
598
+ policy_result_now = (policy_result if verification_time is None
599
+ else evaluate_policy(bundle, result, policy, now=None))
529
600
  # WP4: the --require-anchor gate is a relying-party requirement layered OVER the crypto result,
530
601
  # exactly like --policy — evaluated ONLY when crypto passed (fail-closed; a crypto failure dominates
531
602
  # and exits 1). Unmet → anchor_required_ok False → exit 3. Without the flag it stays None and nothing
@@ -553,7 +624,11 @@ def _cmd_verify(args: argparse.Namespace) -> int:
553
624
  # pins no signer ('attributes to nobody') must NOT set safeForAutomation. Computed once here, reused
554
625
  # for fields["policy_warnings"] below (no double lint).
555
626
  from .policy import policy_warnings as _policy_warnings # noqa: PLC0415
556
- pol_warns = _policy_warnings(policy) if (policy is not None and policy_ok) else []
627
+ # safeForAutomation is a PRESENT-tense verdict, so it reads the CURRENT-time policy evaluation
628
+ # (policy_result_now); in current mode that IS policy_result (Lens-2/3/4/6 review). policy_ok_now is
629
+ # the current-time policy verdict; the exit code + POLICY line keep using the historical policy_ok.
630
+ policy_ok_now = (policy_result_now or {}).get("policy_ok")
631
+ pol_warns = _policy_warnings(policy) if (policy is not None and policy_ok_now) else []
557
632
  # AP-1 §5 (+ L2 pre-land review fix): signer_trusted requires an ACTUALLY-PASSED signer-binding check
558
633
  # on THIS (eval/verify) path — a passing `policy:signer_allowed` from evaluate_policy — NOT merely the
559
634
  # absence of the 'attributes to nobody' warning. That warning counts
@@ -562,31 +637,105 @@ def _cmd_verify(args: argparse.Namespace) -> int:
562
637
  # let a decision-maker-only policy FAKE a trusted signer and yield safeForAutomation:true against an
563
638
  # unpinned bundle signer (fail-open). Key off the positive verdict instead.
564
639
  # AP-2 §6.2: a RAW template (requiresIdentityOverlay:true) never qualifies (no identity overlay applied).
565
- _signer_checks = [c for c in (policy_result or {}).get("checks", [])
640
+ _signer_checks = [c for c in (policy_result_now or {}).get("checks", [])
566
641
  if c.get("name") == "policy:signer_allowed"]
567
642
  signer_matched = bool(_signer_checks) and all(c.get("ok") for c in _signer_checks)
568
- signer_trusted = bool(policy_ok) and signer_matched
643
+ signer_trusted = bool(policy_ok_now) and signer_matched
569
644
  # AP-2 §6.2/§6.4 (L2 pre-land audit): a RAW template (requiresIdentityOverlay:true) and an EXPIRED policy
570
645
  # each force safeForAutomation:false with their OWN honest blocker (TEMPLATE_NOT_INSTANTIATED /
571
646
  # POLICY_EXPIRED) — not a mislabelled SIGNER_NOT_PINNED. signer_trusted now reflects ONLY whether a signer
572
647
  # was actually matched; the template/expiry conditions are surfaced as distinct blockers.
573
648
  _requires_overlay = (policy or {}).get("requiresIdentityOverlay") is True
574
649
  from .policy import policy_expired as _policy_expired # noqa: PLC0415
575
- pol_expired = bool(policy is not None and _policy_expired(policy))
650
+ from .policy import policy_not_yet_valid as _policy_not_yet_valid # noqa: PLC0415
651
+ pol_expired = bool(policy is not None and _policy_expired(policy)) # at the REAL current time
652
+ pol_not_yet_valid = bool(policy is not None and _policy_not_yet_valid(policy)) # A-P0-2 not-before mirror
653
+ # A-P0-1 §5.3/§5.4: the atomic tree context. True via (a) a verified --trusted-checkpoint whose
654
+ # (root, size) both match, (b) a policy trusted_checkpoints match at CURRENT time (policy_result_now,
655
+ # so an expired-today checkpoint never authenticates even in historical mode), or (c) an RP-supplied
656
+ # --expected-root + --expected-tree-size PAIR that both passed. A naked root pin stays
657
+ # ROOT_BYTES_ONLY and never authorises automation.
658
+ _by_rs = {c.name: c.ok for c in result.checks}
659
+ _pair_requested = expected_root is not None and expected_tree_size is not None
660
+ _pair_passed = bool(_by_rs.get("root-authenticity")) and bool(_by_rs.get("tree-size"))
661
+ _pol_ctx = (policy_result_now or {}).get("tree_context_authenticated")
662
+ # Any authenticated source that DISAGREES dominates (fail-closed, No-Fake, Lens-1 review F1): if a
663
+ # pinned policy checkpoint says the (root, tree_size) do not match, treeContextAuthenticity is FALSE
664
+ # even when a separately-supplied --expected-root/--expected-tree-size pair happens to pass — the
665
+ # relying party contradicted their own trusted inputs, and the safe verdict is "not authenticated".
666
+ _ctx_false = ((_pol_ctx is False) or (cp_supplied and not cp_ok)
667
+ or (_pair_requested and not _pair_passed))
668
+ _ctx_true = ((_pol_ctx is True)
669
+ or (_pair_requested and _pair_passed and (not cp_supplied or cp_ok)))
670
+ if _ctx_false:
671
+ tree_ctx = False
672
+ elif _ctx_true:
673
+ tree_ctx = True
674
+ else:
675
+ tree_ctx = None
676
+ # checkpointAuthenticity reports whether a checkpoint authenticated AND MATCHED this bundle's
677
+ # (root, tree_size) — NOT merely that some pinned checkpoint's signature verified (Lens-3/4 review:
678
+ # a verified-but-non-matching checkpoint must not read PASS, else rootTrustLevel would overclaim
679
+ # CHECKPOINT). PASS only when the checkpoint is the source of a PASS tree context; FAIL when a
680
+ # checkpoint was supplied/pinned but did not authenticate-and-match; NOT_EVALUATED when none supplied.
681
+ _cli_cp_matched = cp_supplied and cp_ok and _pair_passed
682
+ _pol_cp_matched = (policy_result_now or {}).get("tree_context_authenticated") is True
683
+ _cp_present = cp_supplied or ((policy_result_now or {}).get("checkpoint_authenticity") in ("PASS", "FAIL"))
684
+ if not _cp_present:
685
+ cpa = None
686
+ elif _cli_cp_matched or _pol_cp_matched:
687
+ cpa = "PASS"
688
+ else:
689
+ cpa = "FAIL"
576
690
  root_summary = root_authenticity_summary(
577
- result, policy_authenticated_root=(policy_result or {}).get("root_authenticated"),
578
- policy_ok=policy_ok, anchor_ok=anchor_required_ok,
691
+ result, policy_authenticated_root=(policy_result_now or {}).get("root_authenticated"),
692
+ policy_ok=policy_ok_now, anchor_ok=anchor_required_ok,
579
693
  signer_trusted=signer_trusted, policy_warnings=pol_warns, policy_expired=pol_expired,
580
- requires_identity_overlay=_requires_overlay)
694
+ policy_not_yet_valid=pol_not_yet_valid,
695
+ requires_identity_overlay=_requires_overlay,
696
+ tree_context_authenticated=tree_ctx, checkpoint_authenticity=cpa)
581
697
  fields["root_authenticity"] = root_summary
698
+ # A-P0-2 §6.3: the labelled historical-verification report (additive; absent in current mode).
699
+ verification_time_report = None
700
+ if verification_time is not None:
701
+ # CURRENT_POLICY_STATUS is the present-tense lifecycle verdict (why safeForAutomation can be NO
702
+ # even when the historical verification passes). Expiry and not-before are BOTH surfaced now
703
+ # (Lens-2/3/4/6: not-yet-valid was previously reported as NO_EXPIRY, hiding the real state).
704
+ if pol_expired:
705
+ current_status = "EXPIRED"
706
+ elif pol_not_yet_valid:
707
+ current_status = "NOT_YET_VALID"
708
+ elif policy is not None and _policy_expired(policy) is None and _policy_not_yet_valid(policy) is None:
709
+ current_status = "NO_LIFECYCLE_WINDOW"
710
+ else:
711
+ current_status = "VALID"
712
+ verification_time_report = {
713
+ "mode": "HISTORICAL",
714
+ "time": args.verification_time,
715
+ "current_policy_status": current_status,
716
+ "historical_policy_status": ("PASS" if policy_ok
717
+ else "FAIL" if policy_ok is False else "NOT_EVALUATED"),
718
+ }
719
+ fields["verification_time"] = verification_time_report
582
720
  # P0-A (audit 2026-07-13): surface --expected-tree-size as a machine-readable object. The check
583
721
  # itself already runs INDEPENDENTLY in verify_bundle (never gated on --expected-root), so a mismatch
584
722
  # already fails the crypto verdict; this makes status/expected/actual explicit so an integrator can
585
723
  # confirm the gate ran rather than inferring it from a check name. NOT_REQUESTED when the flag is absent.
586
- _ets = getattr(args, "expected_tree_size", None)
724
+ _ets = expected_tree_size # effective: an explicit flag OR the checkpoint-derived size (A-P0-1)
587
725
  _tsc = next((c for c in result.checks if c.name == "tree-size"), None)
726
+ # A tree-size authentication WAS requested via a checkpoint whose signature did not verify: the
727
+ # checkpoint never yielded an expected size, so no `tree-size` check ran — but reporting
728
+ # NOT_REQUESTED would be dishonest (a request was made, it could not be evaluated). Report FAIL, the
729
+ # honest "requested but unauthenticated" verdict (Lens-3 review; the run already exits 1 on the
730
+ # checkpoint-authenticity failure).
731
+ if _tsc is not None:
732
+ _ts_status = "PASS" if _tsc.ok else "FAIL"
733
+ elif cp_supplied and not cp_ok:
734
+ _ts_status = "FAIL"
735
+ else:
736
+ _ts_status = "NOT_REQUESTED"
588
737
  fields["treeSizeExpectation"] = {
589
- "status": (("PASS" if _tsc.ok else "FAIL") if _tsc is not None else "NOT_REQUESTED"),
738
+ "status": _ts_status,
590
739
  "expected": _ets,
591
740
  "actual": (bundle.get("merkle") or {}).get("tree_size") if isinstance(bundle, dict) else None,
592
741
  }
@@ -649,6 +798,8 @@ def _cmd_verify(args: argparse.Namespace) -> int:
649
798
  print(f"ROOT-AUTHENTICITY: {root_summary['rootAuthenticity']} "
650
799
  f"(payload-signature {root_summary['payloadSignature']}, "
651
800
  f"merkle-consistency {root_summary['merkleConsistency']}, "
801
+ f"tree-context {root_summary['treeContextAuthenticity']}, "
802
+ f"root-trust-level {root_summary['rootTrustLevel']}, "
652
803
  f"safe-for-automation {str(root_summary['safeForAutomation']).lower()})")
653
804
  # AP-1 §5.3: a dedicated, human-legible automation verdict — YES, or NO with the reason(s). The
654
805
  # reasons are derived one-for-one from root_summary['automationBlockers'] via the SSOT map, so the
@@ -672,6 +823,11 @@ def _cmd_verify(args: argparse.Namespace) -> int:
672
823
  if policy_ok and warns:
673
824
  line += f" (WARNING: {_safe_line(warns[0].split(':', 1)[0])})"
674
825
  print(f"POLICY: {line}")
826
+ if verification_time_report is not None:
827
+ # A-P0-2 §6.3: historical mode is LABELLED — the current and the as-of status both show.
828
+ print(f"VERIFICATION_TIME: HISTORICAL ({_safe_line(str(args.verification_time))})")
829
+ print(f"CURRENT_POLICY_STATUS: {verification_time_report['current_policy_status']}")
830
+ print(f"HISTORICAL_POLICY_STATUS: {verification_time_report['historical_policy_status']}")
675
831
  print(f"ASSURANCE: {assurance_line}")
676
832
  print(f"LIMITATIONS: {VERIFY_NON_MEANING}")
677
833
  # WP4: the ANCHOR line is printed ONLY when --require-anchor was given (default output unchanged).
@@ -1330,6 +1486,25 @@ def build_parser() -> argparse.ArgumentParser:
1330
1486
  verify.add_argument("--expected-tree-size", dest="expected_tree_size", default=None, type=int,
1331
1487
  metavar="N", help="require the merkle tree_size to equal N (guards tree-size "
1332
1488
  "substitution); a mismatch FAILS")
1489
+ verify.add_argument("--trusted-checkpoint", dest="trusted_checkpoint", default=None, metavar="FILE",
1490
+ help="A-P0-1: a signed C2SP checkpoint note file (SPEC §7c) as the ONE "
1491
+ "authenticated source of BOTH the expected root AND tree size — the "
1492
+ "atomic tree context. Needs --checkpoint-vkey. A bundle whose root or "
1493
+ "tree_size differs from the checkpoint FAILS (exit 1); a checkpoint "
1494
+ "that does not verify under the vkey FAILS (exit 1). Only this (or a "
1495
+ "policy trusted_checkpoints match, or supplying BOTH --expected-root "
1496
+ "and --expected-tree-size) reaches TREE_CONTEXT_AUTHENTICITY: PASS")
1497
+ verify.add_argument("--checkpoint-vkey", dest="checkpoint_vkey", default=None, metavar="VKEY",
1498
+ help="the checkpoint log's C2SP verifier key (name+hexKeyID+base64KeyMaterial) "
1499
+ "for --trusted-checkpoint")
1500
+ verify.add_argument("--verification-time", dest="verification_time", default=None, metavar="ISO8601",
1501
+ help="A-P0-2 §6.3: verify the supplied --policy AS OF this explicit PAST "
1502
+ "instant (e.g. 2026-01-01T00:00:00Z; a future instant is a usage error). "
1503
+ "Output is labelled VERIFICATION_TIME: HISTORICAL with both the CURRENT and "
1504
+ "the HISTORICAL policy status — never a silent backdating. safeForAutomation "
1505
+ "is a present-tense verdict and stays false for a policy expired OR "
1506
+ "not-yet-valid TODAY (or an expired-today checkpoint), even in historical "
1507
+ "mode. Requires --policy")
1333
1508
  verify.add_argument("--policy", default=None,
1334
1509
  help="path to a trust-policy JSON (proofbundle/trust-policy/v0.1), OR the name "
1335
1510
  "of a packaged profile (WP3, e.g. strict-eval-v1 — see "
@@ -439,7 +439,11 @@ def verify_decision_receipt(envelope: dict, public_key: bytes, *, strict: bool =
439
439
  _val = predicate.get("validity")
440
440
  _validity = _val if isinstance(_val, dict) else {}
441
441
  if expected_audience is not None:
442
- r["audience_ok"] = expected_audience in (_validity.get("audience") or [])
442
+ # audience MUST be a real JSON array: with a STRING value Python's `in` degrades to
443
+ # SUBSTRING matching ("rp.example" in "rp.example" is True) — a wrong-TYPE audience
444
+ # would satisfy the binding (found by the 3.1.3 regression corpus, fail-closed now).
445
+ _aud = _validity.get("audience")
446
+ r["audience_ok"] = isinstance(_aud, list) and expected_audience in _aud
443
447
  if not r["audience_ok"]:
444
448
  r["errors"].append(
445
449
  "audience mismatch or absent validity.audience — requested audience binding cannot be "
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "schema": "proofbundle/trust-policy/v0.2",
3
3
  "policy_id": "proofbundle-policy/decision-receipt-template-v1",
4
+ "policyPurpose": "decision",
4
5
  "deploymentReady": false,
5
6
  "requiresIdentityOverlay": true,
6
7
  "allowed_schema_versions": [
@@ -0,0 +1,16 @@
1
+ {
2
+ "schema": "proofbundle/trust-policy/v0.1",
3
+ "policy_id": "proofbundle-policy/research-preview-v1",
4
+ "policyPurpose": "eval",
5
+ "allowed_schema_versions": [
6
+ "proofbundle/v0.1"
7
+ ],
8
+ "signature": {
9
+ "allowed_algs": [
10
+ "ed25519"
11
+ ]
12
+ },
13
+ "merkle": {
14
+ "required_hash_alg": "sha256-rfc6962"
15
+ }
16
+ }
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "schema": "proofbundle/trust-policy/v0.1",
3
3
  "policy_id": "proofbundle-policy/strict-eval-authenticated-root-template-v1",
4
+ "policyPurpose": "eval",
4
5
  "deploymentReady": false,
5
6
  "requiresIdentityOverlay": true,
6
7
  "allowed_schema_versions": [
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "schema": "proofbundle/trust-policy/v0.1",
3
3
  "policy_id": "proofbundle-policy/strict-eval-template-v1",
4
+ "policyPurpose": "eval",
4
5
  "deploymentReady": false,
5
6
  "requiresIdentityOverlay": true,
6
7
  "allowed_schema_versions": [
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "schema": "proofbundle/trust-policy/v0.2",
3
3
  "policy_id": "proofbundle-policy/strict-prereg-template-v1",
4
+ "policyPurpose": "eval",
4
5
  "deploymentReady": false,
5
6
  "requiresIdentityOverlay": true,
6
7
  "allowed_schema_versions": [