proofbundle 3.2.1__tar.gz → 3.2.2__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 (190) hide show
  1. {proofbundle-3.2.1/src/proofbundle.egg-info → proofbundle-3.2.2}/PKG-INFO +1 -1
  2. {proofbundle-3.2.1 → proofbundle-3.2.2}/pyproject.toml +1 -1
  3. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/__init__.py +1 -1
  4. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/cli.py +33 -4
  5. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/decision.py +117 -16
  6. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/outcome.py +22 -0
  7. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/public_transparency.py +115 -2
  8. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/renewal.py +116 -15
  9. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/sdjwt.py +53 -19
  10. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/trust_pack.py +110 -17
  11. {proofbundle-3.2.1 → proofbundle-3.2.2/src/proofbundle.egg-info}/PKG-INFO +1 -1
  12. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle.egg-info/SOURCES.txt +3 -0
  13. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_verify.py +69 -0
  14. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_public_transparency.py +136 -0
  15. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_renewal.py +109 -0
  16. proofbundle-3.2.2/tests/test_schema_parity.py +190 -0
  17. proofbundle-3.2.2/tests/test_sdjwt_resource_budget.py +193 -0
  18. proofbundle-3.2.2/tests/test_trust_pack_crypto_agility.py +361 -0
  19. {proofbundle-3.2.1 → proofbundle-3.2.2}/LICENSE +0 -0
  20. {proofbundle-3.2.1 → proofbundle-3.2.2}/README.md +0 -0
  21. {proofbundle-3.2.1 → proofbundle-3.2.2}/setup.cfg +0 -0
  22. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/_inspect_registry.py +0 -0
  23. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/_integration.py +0 -0
  24. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/_strict_json.py +0 -0
  25. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/__init__.py +0 -0
  26. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/_provenance.py +0 -0
  27. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/eee.py +0 -0
  28. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/inspect_ai.py +0 -0
  29. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/lm_eval.py +0 -0
  30. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/promptfoo.py +0 -0
  31. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/adapters/samples.py +0 -0
  32. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors.py +0 -0
  33. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors_chia.py +0 -0
  34. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors_chia_add.py +0 -0
  35. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors_markovian.py +0 -0
  36. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors_ots.py +0 -0
  37. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/anchors_rfc3161.py +0 -0
  38. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/beacon.py +0 -0
  39. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/bundle.py +0 -0
  40. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/canonical.py +0 -0
  41. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/checkpoint.py +0 -0
  42. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/demo.py +0 -0
  43. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/dsse.py +0 -0
  44. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/eee_eval_schema.json +0 -0
  45. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/emit.py +0 -0
  46. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/errors.py +0 -0
  47. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/evalclaim.py +0 -0
  48. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/evidence_pack.py +0 -0
  49. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/experimental/__init__.py +0 -0
  50. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/experimental/enclave.py +0 -0
  51. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/hashalg.py +0 -0
  52. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/hf_evals.py +0 -0
  53. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/inspect_hook.py +0 -0
  54. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/intoto.py +0 -0
  55. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/kbjwt.py +0 -0
  56. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/merkle.py +0 -0
  57. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/persample.py +0 -0
  58. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policies/decision-receipt-template-v1.json +0 -0
  59. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policies/research-preview-v1.json +0 -0
  60. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policies/strict-eval-authenticated-root-template-v1.json +0 -0
  61. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policies/strict-eval-template-v1.json +0 -0
  62. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policies/strict-prereg-template-v1.json +0 -0
  63. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policy.py +0 -0
  64. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/policy_profiles.py +0 -0
  65. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/pqsig.py +0 -0
  66. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/prereg.py +0 -0
  67. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/py.typed +0 -0
  68. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/pytest_plugin.py +0 -0
  69. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/run_ledger.py +0 -0
  70. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/sdjwt_issue.py +0 -0
  71. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/sdjwt_vc.py +0 -0
  72. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/signature.py +0 -0
  73. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/statuslist.py +0 -0
  74. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/subject_binding.py +0 -0
  75. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/tlogproof.py +0 -0
  76. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle/verification_summary.py +0 -0
  77. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  78. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle.egg-info/entry_points.txt +0 -0
  79. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle.egg-info/requires.txt +0 -0
  80. {proofbundle-3.2.1 → proofbundle-3.2.2}/src/proofbundle.egg-info/top_level.txt +0 -0
  81. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_adapters.py +0 -0
  82. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_adversarial.py +0 -0
  83. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchor_hardening_321.py +0 -0
  84. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchor_longevity_property.py +0 -0
  85. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchor_target_trustedtime.py +0 -0
  86. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_chia.py +0 -0
  87. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_chia_add.py +0 -0
  88. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_chia_claims.py +0 -0
  89. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_generic.py +0 -0
  90. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_markovian.py +0 -0
  91. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_ots.py +0 -0
  92. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_anchors_rfc3161.py +0 -0
  93. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_beacon.py +0 -0
  94. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_branch_base_check.py +0 -0
  95. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_bundle.py +0 -0
  96. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_bundle_robustness.py +0 -0
  97. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_canonical.py +0 -0
  98. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_checkpoint.py +0 -0
  99. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_checkpoint_external_vectors.py +0 -0
  100. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_checkpoint_quorum_property.py +0 -0
  101. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_claims_hygiene.py +0 -0
  102. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_cli.py +0 -0
  103. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_cli_eval.py +0 -0
  104. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_cli_require_anchor.py +0 -0
  105. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_conformance.py +0 -0
  106. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_content_root_property.py +0 -0
  107. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_cosignature.py +0 -0
  108. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_cosignature_mldsa.py +0 -0
  109. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_anchors.py +0 -0
  110. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_cli.py +0 -0
  111. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_evidence.py +0 -0
  112. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_fuzz.py +0 -0
  113. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_hardening.py +0 -0
  114. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_policy.py +0 -0
  115. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_schema.py +0 -0
  116. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_decision_validator_api.py +0 -0
  117. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_demo.py +0 -0
  118. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_docs_truth.py +0 -0
  119. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_dsse_adversarial.py +0 -0
  120. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_dup_key_reject.py +0 -0
  121. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_ed25519_semantics.py +0 -0
  122. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_eee.py +0 -0
  123. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_emit.py +0 -0
  124. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_eval_claim_schema.py +0 -0
  125. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_eval_evidence_class.py +0 -0
  126. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_evalclaim.py +0 -0
  127. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_evidence_pack.py +0 -0
  128. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_examples.py +0 -0
  129. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_experimental_enclave.py +0 -0
  130. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_fork_pr_secret_isolation.py +0 -0
  131. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_fuzz_parsers.py +0 -0
  132. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_hashalg.py +0 -0
  133. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_hf_entry_verify_eee_digest.py +0 -0
  134. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_hf_evals.py +0 -0
  135. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_inspect_hook.py +0 -0
  136. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto.py +0 -0
  137. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_claims_hygiene.py +0 -0
  138. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_content_root_migration.py +0 -0
  139. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_dsse.py +0 -0
  140. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_eval_result.py +0 -0
  141. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_examples.py +0 -0
  142. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_spec_diff.py +0 -0
  143. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_intoto_svr.py +0 -0
  144. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_kbjwt.py +0 -0
  145. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_lens_review_fixes_3_1_3.py +0 -0
  146. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_merkle.py +0 -0
  147. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_merkle_consistency_property.py +0 -0
  148. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_merkle_property.py +0 -0
  149. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_mldsa_acvp_vectors.py +0 -0
  150. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_official_vectors.py +0 -0
  151. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_ok_semantics.py +0 -0
  152. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_ots_external_vectors.py +0 -0
  153. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_outcome_verify.py +0 -0
  154. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_oversized_int_guard.py +0 -0
  155. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_persample.py +0 -0
  156. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_pinned_key_validation.py +0 -0
  157. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_policy_explain_lint.py +0 -0
  158. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_policy_lifecycle_purpose.py +0 -0
  159. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_policy_profiles.py +0 -0
  160. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_policy_templates.py +0 -0
  161. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_pqsig.py +0 -0
  162. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_predicate_type_enforcement.py +0 -0
  163. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_prereg.py +0 -0
  164. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_promptfoo.py +0 -0
  165. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_provenance.py +0 -0
  166. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_pytest_plugin.py +0 -0
  167. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_rekor_interop.py +0 -0
  168. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_release_integrity_gate.py +0 -0
  169. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_renewal_policy.py +0 -0
  170. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_renewal_signed.py +0 -0
  171. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_rfc6962_external_vectors.py +0 -0
  172. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_root_authenticity.py +0 -0
  173. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_run_ledger.py +0 -0
  174. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_schema.py +0 -0
  175. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_adversarial.py +0 -0
  176. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_duplicate_cnf.py +0 -0
  177. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_issue.py +0 -0
  178. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_reference.py +0 -0
  179. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_vc.py +0 -0
  180. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwt_verify_binding.py +0 -0
  181. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_sdjwtvc_external_vectors.py +0 -0
  182. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_signature.py +0 -0
  183. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_statuslist.py +0 -0
  184. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_subject_binding.py +0 -0
  185. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_tlogproof.py +0 -0
  186. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_tree_context_authenticity.py +0 -0
  187. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_trust_pack.py +0 -0
  188. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_trust_policy.py +0 -0
  189. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_verification_summary.py +0 -0
  190. {proofbundle-3.2.1 → proofbundle-3.2.2}/tests/test_verify_matrix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 3.2.1
3
+ Version: 3.2.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "3.2.1"
7
+ version = "3.2.2"
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,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "3.2.1"
16
+ __version__ = "3.2.2"
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
@@ -1198,7 +1198,8 @@ def _cmd_decision_verify(args: argparse.Namespace) -> int:
1198
1198
  rp_trust = merged
1199
1199
  result = verify_decision_receipt(env, pub, strict=args.strict, expected_audience=args.aud,
1200
1200
  expected_nonce=args.nonce, policy=policy, anchors=anchors,
1201
- rp_trust=rp_trust)
1201
+ rp_trust=rp_trust,
1202
+ require_derived_subject=args.require_derived_subject)
1202
1203
  except (ProofBundleError, OSError, ValueError) as exc:
1203
1204
  print(f"ERROR: {exc}", file=sys.stderr)
1204
1205
  return 2
@@ -1210,7 +1211,7 @@ def _cmd_decision_verify(args: argparse.Namespace) -> int:
1210
1211
  report = {k: result[k] for k in (
1211
1212
  "ok", "crypto_ok", "structure_ok", "predicate_type_ok", "signer_trusted", "policy_ok",
1212
1213
  "evidence_bound", "audience_ok", "nonce_ok", "freshness_ok", "anchors_ok",
1213
- "action_outcome_proven", "warnings", "errors",
1214
+ "action_outcome_proven", "subject_binding", "subject_derived_ok", "warnings", "errors",
1214
1215
  )}
1215
1216
  print(json.dumps(report, indent=2))
1216
1217
  else:
@@ -1228,6 +1229,10 @@ def _cmd_decision_verify(args: argparse.Namespace) -> int:
1228
1229
  print(f"ANCHORS: {'OK' if result['anchors_ok'] else 'FAIL'}")
1229
1230
  if result["action_outcome_proven"] is False:
1230
1231
  print("ASSURANCE: actionOutcome=executed is self-asserted (no signed outcomeRef)")
1232
+ if result["subject_binding"] is not None:
1233
+ print(f"SUBJECT: {result['subject_binding']['mode']}")
1234
+ if result["subject_derived_ok"] is not None:
1235
+ print(f"SUBJECT_DERIVED: {'OK' if result['subject_derived_ok'] else 'FAIL'}")
1231
1236
  for e in result["errors"]:
1232
1237
  print(f" - {e}", file=sys.stderr)
1233
1238
  for w in result["warnings"]:
@@ -1248,6 +1253,10 @@ def _cmd_decision_verify(args: argparse.Namespace) -> int:
1248
1253
  # like the eval verify path, never a silent exit 0 (lens 3 defect).
1249
1254
  if result["audience_ok"] is False or result["nonce_ok"] is False:
1250
1255
  return 2
1256
+ # Finding 05: a caller who explicitly requests --require-derived-subject is asking for the same kind of
1257
+ # fail-closed binding as --aud/--nonce — an EXTERNAL_ATTESTED subject then must not exit 0.
1258
+ if result["subject_derived_ok"] is False:
1259
+ return 2
1251
1260
  # A supplied anchor that FAILS to verify (broken / root-mismatched / unknown type) is a tamper
1252
1261
  # signal — fail-closed, never a silent exit 0 (fix-review Finding 3). None = no anchor supplied.
1253
1262
  if result["anchors_ok"] is False:
@@ -1362,14 +1371,16 @@ def _cmd_outcome_verify(args: argparse.Namespace) -> int:
1362
1371
  result = verify_outcome_receipt(
1363
1372
  env, pub, strict=args.strict,
1364
1373
  expected_decision_ref=args.expected_decision_ref, decision_maker_id=args.decision_maker_id,
1365
- expected_audience=args.aud, expected_nonce=args.nonce)
1374
+ expected_audience=args.aud, expected_nonce=args.nonce,
1375
+ require_derived_subject=args.require_derived_subject)
1366
1376
  except (ProofBundleError, OSError, ValueError) as exc:
1367
1377
  print(f"ERROR: {exc}", file=sys.stderr)
1368
1378
  return 2
1369
1379
  if args.json:
1370
1380
  report = {k: result[k] for k in (
1371
1381
  "ok", "crypto_ok", "structure_ok", "predicate_type_ok", "decision_bound", "role_separation_ok",
1372
- "execution_proven", "audience_ok", "nonce_ok", "warnings", "errors",
1382
+ "execution_proven", "audience_ok", "nonce_ok", "subject_binding", "subject_derived_ok",
1383
+ "warnings", "errors",
1373
1384
  )}
1374
1385
  print(json.dumps(report, indent=2))
1375
1386
  else:
@@ -1385,6 +1396,10 @@ def _cmd_outcome_verify(args: argparse.Namespace) -> int:
1385
1396
  print(f"NONCE: {'OK' if result['nonce_ok'] else 'MISMATCH'}")
1386
1397
  if result["execution_proven"] is False:
1387
1398
  print("ASSURANCE: status=executed is self-asserted (no effectDigest/actualActionDigest)")
1399
+ if result["subject_binding"] is not None:
1400
+ print(f"SUBJECT: {result['subject_binding']['mode']}")
1401
+ if result["subject_derived_ok"] is not None:
1402
+ print(f"SUBJECT_DERIVED: {'OK' if result['subject_derived_ok'] else 'FAIL'}")
1388
1403
  for e in result["errors"]:
1389
1404
  print(f" - {e}", file=sys.stderr)
1390
1405
  for w in result["warnings"]:
@@ -1403,6 +1418,10 @@ def _cmd_outcome_verify(args: argparse.Namespace) -> int:
1403
1418
  return 2
1404
1419
  if result["audience_ok"] is False or result["nonce_ok"] is False:
1405
1420
  return 2
1421
+ # Finding 05: a caller who explicitly requests --require-derived-subject is asking for the same kind of
1422
+ # fail-closed binding as --aud/--nonce — an EXTERNAL_ATTESTED subject then must not exit 0.
1423
+ if result["subject_derived_ok"] is False:
1424
+ return 2
1406
1425
  return 0
1407
1426
 
1408
1427
 
@@ -1888,6 +1907,11 @@ def build_parser() -> argparse.ArgumentParser:
1888
1907
  d_verify.add_argument("--bitcoin-header", action="append", default=None, metavar="HEIGHT:MERKLEROOT_HEX",
1889
1908
  help="WP-A1: a relying-party-supplied Bitcoin block header (internal byte order) "
1890
1909
  "that confirms an OpenTimestamps statement anchor — frozen is never trusted")
1910
+ d_verify.add_argument("--require-derived-subject", dest="require_derived_subject", action="store_true",
1911
+ help="Finding 05: fail closed (exit 2) unless the Statement subject is a DERIVED "
1912
+ "commitment to the predicate (subject_binding.classify_subject) — rejects a "
1913
+ "self-attested/rehung subject_sha256 override. Default off: an "
1914
+ "EXTERNAL_ATTESTED subject is still warned, never silent")
1891
1915
  d_verify.set_defaults(func=_cmd_decision_verify)
1892
1916
 
1893
1917
  d_inspect = dsub.add_parser("inspect", help="print a decision receipt's predicate (no crypto verification)")
@@ -1927,6 +1951,11 @@ def build_parser() -> argparse.ArgumentParser:
1927
1951
  o_verify.add_argument("--strict", action="store_true", help="enforce strict-v0.1 required fields")
1928
1952
  o_verify.add_argument("--aud", default=None, help="expected audience (checks validity.audience against replay)")
1929
1953
  o_verify.add_argument("--nonce", default=None, help="expected nonce (checks validity.nonce against replay)")
1954
+ o_verify.add_argument("--require-derived-subject", dest="require_derived_subject", action="store_true",
1955
+ help="Finding 05: fail closed (exit 2) unless the Statement subject is a DERIVED "
1956
+ "commitment to the predicate (subject_binding.classify_subject) — rejects a "
1957
+ "self-attested/rehung subject_sha256 override. Default off: an "
1958
+ "EXTERNAL_ATTESTED subject is still warned, never silent")
1930
1959
  o_verify.set_defaults(func=_cmd_outcome_verify)
1931
1960
 
1932
1961
  o_inspect = osub.add_parser("inspect", help="print an outcome receipt's predicate (no crypto verification)")
@@ -16,6 +16,7 @@ from typing import Any
16
16
 
17
17
  from ._strict_json import loads_strict
18
18
  from .errors import BundleFormatError, ProofBundleError
19
+ from .subject_binding import nested_closure_violations
19
20
 
20
21
  DECISION_RECEIPT_PREDICATE_TYPE = "https://b7n0de.com/proofbundle/predicates/decision-receipt/v0.1"
21
22
  DECISION_SCHEMA_VERSION = "0.1.0"
@@ -53,6 +54,35 @@ _ALLOWED_TOP = set(_REQUIRED_ALWAYS) | set(_OPTIONAL)
53
54
  # Time-bearing paths that, if present, MUST be RFC3339-Z.
54
55
  _TIME_PATHS = ("decidedAt", "recordedAt")
55
56
 
57
+ # Nested schema closure (Finding 04 / subject_binding.nested_closure_violations): a top-level
58
+ # additionalProperties:false does NOT, by itself, close these nested objects — an undeclared key inside one
59
+ # of them (e.g. `decision: {..., "sneaky": 1}`) previously rode along silently. Every path here was verified
60
+ # against the golden examples (examples/decision_receipt_{allow,deny,escalate}.json,
61
+ # decision_receipt_with_eval_ref.intoto.json) AND the `decision init` CLI template so this closure can never
62
+ # reject the repo's own reference material. `decisionMaker.version` is DELIBERATELY left undeclared — it is
63
+ # the versioned extensions container the module's own docstring describes as "the ONLY way to add fields".
64
+ _NESTED_ALLOWED: dict[str, tuple[str, ...]] = {
65
+ "decisionMaker": ("id", "version"),
66
+ "agent": ("id", "version", "configurationDigest"),
67
+ "principal": ("id", "authnContextRef"),
68
+ "principal.authnContextRef": ("uri", "digest"),
69
+ "proposedAction": ("actionType", "target", "method", "parametersDigest", "parametersRef",
70
+ "parametersSchemaRef"),
71
+ "proposedAction.target": ("name", "uri", "digest"),
72
+ "proposedAction.parametersSchemaRef": ("uri", "digest"),
73
+ "policyBoundary": ("policyEngine", "policyId", "policyDigest", "decisionPath", "bundleRevision",
74
+ "validFrom", "validUntil"),
75
+ "evidenceRefs[]": ("relation", "digest", "artifactDigest", "uri", "predicateType"),
76
+ "decision": ("verdict", "reasonCodes", "humanReadableSummary", "obligations", "allowedScope"),
77
+ "notChecked[]": ("field", "reason", "impact"),
78
+ "decisionChangeConditions[]": ("conditionType", "description", "requiredEvidenceType"),
79
+ "actionOutcome": ("status", "performedAt", "outcomeRef"),
80
+ "actionOutcome.outcomeRef": ("uri", "digest"),
81
+ "privacy": ("rawInputsIncluded", "redactionProfile", "erased", "masked"),
82
+ "validity": ("audience", "nonce", "expiresAt"),
83
+ "inputSnapshot[]": ("name", "uri", "digest", "mediaType"),
84
+ }
85
+
56
86
 
57
87
  class DecisionReceiptError(ProofBundleError):
58
88
  """A Decision Receipt predicate is malformed (fail-closed)."""
@@ -101,6 +131,13 @@ def validate_decision_predicate(predicate: Any, *, strict: bool = False) -> list
101
131
  if sv is not None and (not isinstance(sv, str) or not _SEMVER_0_1_X.match(sv)):
102
132
  errors.append(f"schemaVersion must be a 0.1.x string, got {sv!r}")
103
133
 
134
+ # decisionId (Finding 04, analogous to trustPackId in trust_pack.py): the required-field loop above only
135
+ # checks PRESENCE — an int, a bool or an empty string previously satisfied "decisionId in predicate" and
136
+ # passed silently.
137
+ did = predicate.get("decisionId")
138
+ if "decisionId" in predicate and not (isinstance(did, str) and did):
139
+ errors.append(f"decisionId must be a non-empty string, got {did!r}")
140
+
104
141
  # decisionType enum
105
142
  dt = predicate.get("decisionType")
106
143
  if dt is not None and (not isinstance(dt, str) or dt not in _DECISION_TYPES):
@@ -135,13 +172,19 @@ def validate_decision_predicate(predicate: Any, *, strict: bool = False) -> list
135
172
  elif "policyBoundary" in predicate:
136
173
  errors.append("policyBoundary must be a JSON object")
137
174
 
138
- # proposedAction inner shape (§6.2): actionType required + a digest OR a ref for the parameters.
175
+ # proposedAction inner shape (§6.2): actionType required + a digest OR a non-empty ref for the
176
+ # parameters. Finding 04: a bare `{}` parametersRef previously satisfied `isinstance(..., dict)`
177
+ # vacuously — `{}` is a dict but carries no actual reference.
139
178
  pa = predicate.get("proposedAction")
140
179
  if isinstance(pa, dict):
141
180
  if not isinstance(pa.get("actionType"), str) or not pa.get("actionType"):
142
181
  errors.append("proposedAction.actionType must be a non-empty string")
143
- if not (_is_digest(pa.get("parametersDigest")) or isinstance(pa.get("parametersRef"), dict)):
144
- errors.append("proposedAction needs a sha256 parametersDigest or a parametersRef")
182
+ pref = pa.get("parametersRef")
183
+ pref_ok = isinstance(pref, dict) and bool(pref)
184
+ if "parametersRef" in pa and not pref_ok:
185
+ errors.append("proposedAction.parametersRef, when present, must be a non-empty object")
186
+ if not (_is_digest(pa.get("parametersDigest")) or pref_ok):
187
+ errors.append("proposedAction needs a sha256 parametersDigest or a non-empty parametersRef")
145
188
  elif "proposedAction" in predicate:
146
189
  errors.append("proposedAction must be a JSON object")
147
190
 
@@ -180,20 +223,28 @@ def validate_decision_predicate(predicate: Any, *, strict: bool = False) -> list
180
223
  elif "evidenceRefs" in predicate:
181
224
  errors.append("evidenceRefs must be a list")
182
225
 
183
- # actionOutcome (optional) — status enum
226
+ # actionOutcome (optional) — must be an object; status enum. Finding 04: a scalar (e.g. the bare
227
+ # string "executed") previously skipped every check silently (the `isinstance(ao, dict)` guard simply
228
+ # never entered the block, and nothing else objected to the wrong shape).
184
229
  ao = predicate.get("actionOutcome")
185
230
  if isinstance(ao, dict):
186
231
  st = ao.get("status")
187
232
  if not isinstance(st, str) or st not in _OUTCOME_STATUS:
188
233
  errors.append(f"actionOutcome.status must be one of {sorted(_OUTCOME_STATUS)}, got {st!r}")
234
+ elif "actionOutcome" in predicate:
235
+ errors.append("actionOutcome must be a JSON object")
189
236
 
190
- # validity — strict interactive requires audience + nonce when present
237
+ # validity — must be an object; strict interactive mode additionally requires audience + nonce when
238
+ # present. Finding 04: a scalar validity previously passed the same way actionOutcome did.
191
239
  val = predicate.get("validity")
192
- if isinstance(val, dict) and strict:
193
- if not (isinstance(val.get("audience"), list) and val["audience"]):
194
- errors.append("validity.audience (non-empty list) is required in strict mode when validity is present")
195
- if not isinstance(val.get("nonce"), str) or not val.get("nonce"):
196
- errors.append("validity.nonce is required in strict mode when validity is present")
240
+ if isinstance(val, dict):
241
+ if strict:
242
+ if not (isinstance(val.get("audience"), list) and val["audience"]):
243
+ errors.append("validity.audience (non-empty list) is required in strict mode when validity is present")
244
+ if not isinstance(val.get("nonce"), str) or not val.get("nonce"):
245
+ errors.append("validity.nonce is required in strict mode when validity is present")
246
+ elif "validity" in predicate:
247
+ errors.append("validity must be a JSON object")
197
248
 
198
249
  # privacy inner shape: a bare {} must not pass strict — rawInputsIncluded is the field the policy's
199
250
  # allow_raw_inputs gate reads, so it MUST be an explicit boolean (§5.5 privacy).
@@ -203,6 +254,11 @@ def validate_decision_predicate(predicate: Any, *, strict: bool = False) -> list
203
254
  elif strict and isinstance(priv, dict) and not isinstance(priv.get("rawInputsIncluded"), bool):
204
255
  errors.append("privacy.rawInputsIncluded (boolean) is required in strict mode")
205
256
 
257
+ # Nested schema closure (Finding 04): additionalProperties:false at the TOP level does not, by itself,
258
+ # close these nested objects — an undeclared key inside decision/policyBoundary/proposedAction/
259
+ # decisionMaker/evidenceRefs[] (and their sub-objects) previously rode along silently.
260
+ errors.extend(nested_closure_violations(predicate, _NESTED_ALLOWED))
261
+
206
262
  return errors
207
263
 
208
264
 
@@ -333,6 +389,7 @@ def _empty_result() -> dict:
333
389
  "ok": None, "structure_ok": None, "crypto_ok": None, "signer_trusted": None,
334
390
  "predicate_type_ok": None, "policy_ok": None, "evidence_bound": None, "audience_ok": None,
335
391
  "nonce_ok": None, "freshness_ok": None, "anchors_ok": None, "action_outcome_proven": None,
392
+ "subject_binding": None, "subject_derived_ok": None,
336
393
  "warnings": [], "errors": [],
337
394
  }
338
395
 
@@ -340,7 +397,7 @@ def _empty_result() -> dict:
340
397
  def verify_decision_receipt(envelope: dict, public_key: bytes, *, strict: bool = False,
341
398
  expected_audience: str | None = None, expected_nonce: str | None = None,
342
399
  policy: dict | None = None, anchors: list | None = None,
343
- rp_trust: dict | None = None) -> dict:
400
+ rp_trust: dict | None = None, require_derived_subject: bool = False) -> dict:
344
401
  """Verify a DSSE-signed Decision Receipt. Crypto first, then structure over the EXACT signed bytes (never
345
402
  re-serialized). Returns the snake_case structured result; each check independent, non-applicable = None.
346
403
 
@@ -348,14 +405,21 @@ def verify_decision_receipt(envelope: dict, public_key: bytes, *, strict: bool =
348
405
  verdict (crypto AND structure AND predicate-type AND every applicable trust check). The individual
349
406
  fields describe the payload only AFTER authentication: when `crypto_ok` is False the bytes are
350
407
  unauthenticated, so the trust-derived fields (`audience_ok`, `nonce_ok`, `evidence_bound`,
351
- `signer_trusted`, `policy_ok`, `action_outcome_proven`) stay None — and `anchors_ok` is None, or
352
- False when anchors were supplied — and an error is recorded, so a consumer that reads e.g.
353
- `audience_ok` without checking `crypto_ok`/`ok` cannot read a claim about bytes nobody signed. The
354
- CLI gates its exit code on `crypto_ok` first (and reports `ok`).
408
+ `signer_trusted`, `policy_ok`, `action_outcome_proven`, `subject_derived_ok`) stay None — and
409
+ `anchors_ok` is None, or False when anchors were supplied — and an error is recorded, so a consumer
410
+ that reads e.g. `audience_ok` without checking `crypto_ok`/`ok` cannot read a claim about bytes nobody
411
+ signed. The CLI gates its exit code on `crypto_ok` first (and reports `ok`).
355
412
 
356
413
  hash_binding (§7.1): the received payload MUST equal its own RFC-8785 canonicalization; a deviation is a
357
414
  fail-closed error (only checked when rfc8785 is importable, so plain verify stays dependency-free).
358
415
 
416
+ Subject binding (Finding 05, mirrors outcome.py): `build_decision_statement` allows a caller to
417
+ OVERRIDE `subject_sha256`, self-attested and NOT cross-checked there. This verify path now classifies
418
+ the binding via `subject_binding.classify_subject` so a subject-rehang is never a ZERO-signal event: an
419
+ `EXTERNAL_ATTESTED` subject is ALWAYS warned (`subject_binding`/`warnings`); `require_derived_subject`
420
+ (opt-in, default False, preserving the documented override use) makes it a hard fail-closed error via
421
+ `subject_derived_ok`.
422
+
359
423
  Detached anchors (Fix 2 / proofbundle#7): `anchors` is the DETACHED anchor evidence for the decision
360
424
  statement's OWN content root (sha256 over the exact signed payload bytes). It is NOT part of the signed
361
425
  predicate — an anchor cannot live inside the bytes whose hash it commits. It is verified against the
@@ -455,6 +519,42 @@ def verify_decision_receipt(envelope: dict, public_key: bytes, *, strict: bool =
455
519
  "nonce mismatch or absent validity.nonce — requested replay binding cannot be enforced "
456
520
  "(replay?, fail-closed)")
457
521
 
522
+ # Subject binding (Finding 05, release-review #4 parity with outcome.py): classify whether the subject
523
+ # genuinely commits to the predicate so a consumer never gets ZERO signal on a subject-rehang override.
524
+ # An EXTERNAL_ATTESTED subject is ALWAYS warned; require_derived_subject makes it a hard fail-closed
525
+ # error (opt-in, preserves the documented build_decision_statement override use).
526
+ if isinstance(statement, dict) and r["crypto_ok"]:
527
+ if _rfc8785_available():
528
+ from . import subject_binding as _sb # noqa: PLC0415
529
+ try:
530
+ _cls = _sb.classify_subject(statement)
531
+ except Exception: # noqa: BLE001
532
+ _cls = None
533
+ if _cls is not None:
534
+ r["subject_binding"] = {"mode": _cls["mode"], "matches": _cls["matches"]}
535
+ if not _cls["matches"]:
536
+ r["warnings"].append(
537
+ "subject is EXTERNAL_ATTESTED — it does not commit to this predicate (subject-rehang); "
538
+ "trust it only via a policy that pins the external attester")
539
+ if require_derived_subject:
540
+ r["subject_derived_ok"] = _cls["matches"]
541
+ if not _cls["matches"]:
542
+ r["errors"].append(
543
+ "require_derived_subject: subject is not a DERIVED commitment to the predicate "
544
+ "(fail-closed)")
545
+ elif require_derived_subject:
546
+ # classify_subject raised (e.g. a predicate the JCS canonicalizer rejects). Do NOT silently
547
+ # pass the gate on a coincidence elsewhere — make the fail-closed explicit.
548
+ r["subject_derived_ok"] = False
549
+ r["errors"].append(
550
+ "require_derived_subject: could not classify the subject binding (canonicalization raised) "
551
+ "— fail-closed")
552
+ elif require_derived_subject:
553
+ r["subject_derived_ok"] = False
554
+ r["errors"].append(
555
+ "require_derived_subject but RFC-8785 canonicalizer unavailable — cannot verify subject "
556
+ "derivation (install proofbundle[eval]), fail-closed")
557
+
458
558
  # Detached anchors (Fix 2): verify anchor evidence for the statement's OWN content root — sha256 over the
459
559
  # EXACT signed payload bytes (never re-canonicalized), computed only once the bytes are authentic+canonical.
460
560
  _dr_section = policy.get("decision_receipt") if isinstance(policy, dict) else None
@@ -526,5 +626,6 @@ def verify_decision_receipt(envelope: dict, public_key: bytes, *, strict: bool =
526
626
  r["crypto_ok"] and r["structure_ok"] and r["predicate_type_ok"]
527
627
  and r["policy_ok"] is not False and r["signer_trusted"] is not False
528
628
  and r["audience_ok"] is not False and r["nonce_ok"] is not False
529
- and r["evidence_bound"] is not False and r["anchors_ok"] is not False)
629
+ and r["evidence_bound"] is not False and r["anchors_ok"] is not False
630
+ and r["subject_derived_ok"] is not False)
530
631
  return r
@@ -23,6 +23,7 @@ from typing import Any
23
23
 
24
24
  from ._strict_json import loads_strict
25
25
  from .errors import BundleFormatError, ProofBundleError
26
+ from .subject_binding import nested_closure_violations
26
27
 
27
28
  ACTION_OUTCOME_PREDICATE_TYPE = "https://b7n0de.com/proofbundle/predicates/action-outcome/v0.1"
28
29
  OUTCOME_SCHEMA_VERSION = "0.1.0"
@@ -47,6 +48,15 @@ _ALLOWED_TOP = set(_REQUIRED_ALWAYS) | set(_OPTIONAL)
47
48
  _DIGEST_FIELDS = ("requestedActionDigest", "actualActionDigest", "responseDigest", "effectDigest")
48
49
  _TIME_PATHS = ("performedAt", "recordedAt")
49
50
 
51
+ # Nested schema closure (Finding 04 / subject_binding.nested_closure_violations): a top-level
52
+ # additionalProperties:false does NOT, by itself, close traceContext/validity — an undeclared key inside
53
+ # one of them previously rode along silently. Verified against `outcome init`'s CLI template
54
+ # (traceContext={"traceparent": ""}) and tests/test_outcome_verify.py's validity={"audience", "nonce"}.
55
+ _NESTED_ALLOWED: dict[str, tuple[str, ...]] = {
56
+ "traceContext": ("traceparent",),
57
+ "validity": ("audience", "nonce"),
58
+ }
59
+
50
60
 
51
61
  class OutcomeReceiptError(ProofBundleError):
52
62
  """An Action Outcome Receipt predicate is malformed (fail-closed)."""
@@ -125,6 +135,18 @@ def validate_outcome_predicate(predicate: Any, *, strict: bool = False) -> list[
125
135
  if "traceContext" in predicate and not isinstance(tc, dict):
126
136
  errors.append("traceContext must be an object")
127
137
 
138
+ # validity (Finding 04): previously had NO structural check at all — a scalar validity (e.g. the bare
139
+ # string "n-1") silently reached verify_outcome_receipt, which only guards with
140
+ # `_val if isinstance(_val, dict) else {}` and so treated it as an absent validity object without ever
141
+ # flagging the malformed shape.
142
+ val = predicate.get("validity")
143
+ if "validity" in predicate and not isinstance(val, dict):
144
+ errors.append("validity must be an object")
145
+
146
+ # Nested schema closure (Finding 04): additionalProperties:false at the TOP level does not, by itself,
147
+ # close traceContext/validity — an undeclared key inside either previously rode along silently.
148
+ errors.extend(nested_closure_violations(predicate, _NESTED_ALLOWED))
149
+
128
150
  return errors
129
151
 
130
152
 
@@ -23,6 +23,7 @@ general SCITT/RFC-9943 conformance claim without interop vectors, and never that
23
23
  """
24
24
  from __future__ import annotations
25
25
 
26
+ from dataclasses import dataclass
26
27
  from typing import Any
27
28
 
28
29
  from .errors import ProofBundleError
@@ -39,6 +40,79 @@ class PublicTransparencyError(ProofBundleError):
39
40
  """A public-transparency policy is malformed (fail-closed)."""
40
41
 
41
42
 
43
+ @dataclass(frozen=True)
44
+ class ConsistencyVerificationResult:
45
+ """A relying party's (or independent monitor's) OWN result of checking an RFC 9162 §2.1.4 consistency
46
+ proof (``merkle.verify_consistency``) between two checkpoints of the SAME log: the *old* (first,
47
+ smaller) checkpoint the caller previously trusted, and the *new* (second, larger) checkpoint currently
48
+ being evaluated by ``evaluate_public_transparency``.
49
+
50
+ Finding 10: a bare ``consistency_confirmed: bool`` is an UNBOUND plaintext claim — it says nothing
51
+ about WHICH checkpoint pair was checked, so a ``True`` computed for one (old, new) pair is trivially
52
+ replayable onto a different, unrelated ``new`` checkpoint. Carrying the checkpoint identities INSIDE
53
+ the result lets ``evaluate_public_transparency`` bind ``new_origin`` / ``new_tree_size`` /
54
+ ``new_root_b64`` to the EXACT checkpoint under evaluation before it ever accepts ``confirmed``.
55
+
56
+ ``proof_digest`` / ``verifier_version`` / ``policy_digest`` are provenance (which consistency-proof
57
+ bytes were actually checked, by which verifier build, under which policy) — evidence the check really
58
+ ran, not merely a hand-typed ``confirmed=True`` (No-Fake omission floor); ``validate()`` requires them
59
+ non-empty. ``validate()`` also rejects a STRUCTURALLY impossible pair regardless of ``confirmed`` — in
60
+ particular the same tree size claiming two different roots, which is the signature of a log SPLIT VIEW
61
+ (a fork), never a valid consistency claim (RFC 9162: equal size implies equal root)."""
62
+
63
+ old_origin: str
64
+ old_tree_size: int
65
+ old_root_b64: str
66
+ new_origin: str
67
+ new_tree_size: int
68
+ new_root_b64: str
69
+ proof_digest: str
70
+ verifier_version: str
71
+ policy_digest: str
72
+ confirmed: bool
73
+
74
+ def validate(self) -> list[str]:
75
+ """Fail-closed structural validation (empty list = valid). Never raises on malformed field types —
76
+ every comparison is guarded so a caller-constructed or deserialized result cannot crash this."""
77
+ errors: list[str] = []
78
+
79
+ def _str(name: str, val: Any) -> bool:
80
+ ok = isinstance(val, str) and val != ""
81
+ if not ok:
82
+ errors.append(f"{name} must be a non-empty string")
83
+ return ok
84
+
85
+ def _size(name: str, val: Any) -> bool:
86
+ ok = isinstance(val, int) and not isinstance(val, bool) and val >= 0
87
+ if not ok:
88
+ errors.append(f"{name} must be a non-negative integer")
89
+ return ok
90
+
91
+ origin_ok = _str("old_origin", self.old_origin) and _str("new_origin", self.new_origin)
92
+ root_ok = _str("old_root_b64", self.old_root_b64) and _str("new_root_b64", self.new_root_b64)
93
+ _str("proof_digest", self.proof_digest)
94
+ _str("verifier_version", self.verifier_version)
95
+ _str("policy_digest", self.policy_digest)
96
+ size_ok = _size("old_tree_size", self.old_tree_size) and _size("new_tree_size", self.new_tree_size)
97
+ if not isinstance(self.confirmed, bool):
98
+ errors.append("confirmed must be a boolean")
99
+
100
+ if size_ok and self.old_tree_size > self.new_tree_size:
101
+ errors.append(
102
+ "old_tree_size exceeds new_tree_size — a consistency proof only orders old -> new, "
103
+ "never the reverse")
104
+ if size_ok and root_ok and self.old_tree_size == self.new_tree_size \
105
+ and self.old_root_b64 != self.new_root_b64:
106
+ errors.append(
107
+ "old_tree_size == new_tree_size but the roots differ — a split view / fork, never a "
108
+ "valid consistency claim (RFC 9162: identical tree size implies an identical root)")
109
+ if origin_ok and self.old_origin != self.new_origin:
110
+ errors.append(
111
+ "old_origin and new_origin differ — a consistency proof is only meaningful within one "
112
+ "log's own history")
113
+ return errors
114
+
115
+
42
116
  def validate_public_transparency_policy(policy: Any) -> list[str]:
43
117
  """Fail-closed validation of a public-transparency policy object (empty = valid)."""
44
118
  errors: list[str] = []
@@ -72,13 +146,25 @@ def evaluate_public_transparency(
72
146
  signed_note: str, policy: dict, *, log_vkey: str | None = None,
73
147
  witness_vkeys: list | None = None, expected_root_b64: str | None = None,
74
148
  expected_tree_size: int | None = None, consistency_confirmed: bool | None = None,
149
+ consistency_result: "ConsistencyVerificationResult | None" = None,
150
+ strict_consistency: bool = False,
75
151
  ) -> dict:
76
152
  """Evaluate a signed checkpoint against a public-transparency policy → unified named-status verdict.
77
153
 
78
154
  ``consistency_confirmed`` is the relying party's OWN result of a consistency-proof check (append-only, no
79
155
  fork) computed elsewhere; when the policy requires consistency it MUST be True, else FAIL. It is passed in
80
156
  (not computed here) because a consistency proof is between two checkpoints the relying party holds — this
81
- layer records the verdict, fail-closed. Read PUBLIC_TRANSPARENCY — never an individual status alone."""
157
+ layer records the verdict, fail-closed. Read PUBLIC_TRANSPARENCY — never an individual status alone.
158
+
159
+ ``consistency_result`` (finding 10, additive): a typed ``ConsistencyVerificationResult`` instead of the
160
+ bare ``consistency_confirmed`` boolean. Preferred, because it BINDS the confirmation to this exact
161
+ checkpoint — ``new_origin`` / ``new_tree_size`` / ``new_root_b64`` are checked against the values this
162
+ call itself parses from ``signed_note`` BEFORE ``confirmed`` is ever accepted, and its own
163
+ ``.validate()`` rejects a structurally impossible (e.g. split-view) pair regardless of ``confirmed``.
164
+ When both are supplied, ``consistency_result`` takes precedence. ``strict_consistency=True`` refuses
165
+ the unbound bare-boolean path entirely (CONSISTENCY FAILs if only ``consistency_confirmed`` is given) —
166
+ additive: the default (``strict_consistency=False``) preserves every existing caller's behavior
167
+ exactly."""
82
168
  from . import checkpoint as cp # noqa: PLC0415
83
169
 
84
170
  perrs = validate_public_transparency_policy(policy)
@@ -150,8 +236,35 @@ def evaluate_public_transparency(
150
236
  errors.append("checkpoint tree size does not equal the relying party's expected tree size")
151
237
 
152
238
  # CONSISTENCY — required-and-confirmed → PASS; required-and-not-confirmed → FAIL (fail-closed).
239
+ # finding 10: a typed consistency_result (preferred) is BOUND to this exact checkpoint before its
240
+ # `confirmed` is accepted; a bare consistency_confirmed boolean is an unbound plaintext claim, and
241
+ # strict_consistency=True refuses it entirely.
153
242
  if bool(policy.get("requireConsistencyProof")):
154
- if consistency_confirmed is True:
243
+ if consistency_result is not None:
244
+ cerrs = consistency_result.validate()
245
+ if cerrs:
246
+ statuses["CONSISTENCY"] = "FAIL"
247
+ errors.extend(f"consistency result invalid: {e}" for e in cerrs)
248
+ elif not (parsed_ok and consistency_result.new_origin == origin
249
+ and consistency_result.new_tree_size == tree_size
250
+ and consistency_result.new_root_b64 == root):
251
+ statuses["CONSISTENCY"] = "FAIL"
252
+ errors.append(
253
+ "consistency result does not bind to this checkpoint (new_origin/new_tree_size/"
254
+ "new_root_b64 do not match the checkpoint being evaluated) — wrong checkpoint pair, "
255
+ "fail-closed")
256
+ elif consistency_result.confirmed is True:
257
+ statuses["CONSISTENCY"] = "PASS"
258
+ else:
259
+ statuses["CONSISTENCY"] = "FAIL"
260
+ errors.append("consistency result is bound to this checkpoint but confirmed is not True")
261
+ elif strict_consistency:
262
+ statuses["CONSISTENCY"] = "FAIL"
263
+ errors.append(
264
+ "requireConsistencyProof with strict_consistency=True needs a typed "
265
+ "ConsistencyVerificationResult — a bare consistency_confirmed boolean is an unbound "
266
+ "plaintext claim and is rejected (fail-closed)")
267
+ elif consistency_confirmed is True:
155
268
  statuses["CONSISTENCY"] = "PASS"
156
269
  else:
157
270
  statuses["CONSISTENCY"] = "FAIL"