agent-lifecycle-kit 1.50.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. agent_lifecycle/__init__.py +5 -0
  2. agent_lifecycle/__main__.py +5 -0
  3. agent_lifecycle/_version.py +3 -0
  4. agent_lifecycle/adapter_sessions/__init__.py +41 -0
  5. agent_lifecycle/adapter_sessions/contracts.py +133 -0
  6. agent_lifecycle/adapter_sessions/env.py +44 -0
  7. agent_lifecycle/adapter_sessions/launcher.py +101 -0
  8. agent_lifecycle/adapter_sessions/process.py +39 -0
  9. agent_lifecycle/adapter_sessions/redaction.py +30 -0
  10. agent_lifecycle/adapter_sessions/session_store.py +64 -0
  11. agent_lifecycle/adapter_sessions/task_intake.py +450 -0
  12. agent_lifecycle/adapter_sessions/workflow_bridge.py +154 -0
  13. agent_lifecycle/audit/__init__.py +67 -0
  14. agent_lifecycle/audit/bug_forensics.py +94 -0
  15. agent_lifecycle/audit/implementation.py +516 -0
  16. agent_lifecycle/audit/ownership.py +119 -0
  17. agent_lifecycle/audit/proof_integrity.py +814 -0
  18. agent_lifecycle/audit/review_verdict.py +116 -0
  19. agent_lifecycle/changesets/__init__.py +5 -0
  20. agent_lifecycle/changesets/git.py +35 -0
  21. agent_lifecycle/cli/__init__.py +5 -0
  22. agent_lifecycle/cli/adapter.py +360 -0
  23. agent_lifecycle/cli/dispatch.py +1220 -0
  24. agent_lifecycle/cli/followup.py +89 -0
  25. agent_lifecycle/cli/main.py +41 -0
  26. agent_lifecycle/cli/metrics_parser.py +49 -0
  27. agent_lifecycle/cli/parsers.py +618 -0
  28. agent_lifecycle/cli/policy.py +101 -0
  29. agent_lifecycle/cli/progress_hooks.py +117 -0
  30. agent_lifecycle/cli/worktree.py +74 -0
  31. agent_lifecycle/compiler/__init__.py +17 -0
  32. agent_lifecycle/compiler/small_model_packets.py +322 -0
  33. agent_lifecycle/compiler/task_packets.py +229 -0
  34. agent_lifecycle/context/__init__.py +20 -0
  35. agent_lifecycle/context/episode_retrieval.py +30 -0
  36. agent_lifecycle/context/external_memory.py +71 -0
  37. agent_lifecycle/context/profiles.py +104 -0
  38. agent_lifecycle/context/rendering.py +226 -0
  39. agent_lifecycle/contracts/__init__.py +25 -0
  40. agent_lifecycle/contracts/adapter_contract_schemas.py +130 -0
  41. agent_lifecycle/contracts/adapter_event_schemas.py +118 -0
  42. agent_lifecycle/contracts/adapter_session_schemas.py +130 -0
  43. agent_lifecycle/contracts/adapter_task_schemas.py +90 -0
  44. agent_lifecycle/contracts/audit_schemas.py +133 -0
  45. agent_lifecycle/contracts/bug_forensics_schemas.py +400 -0
  46. agent_lifecycle/contracts/canonical.py +55 -0
  47. agent_lifecycle/contracts/compatibility.py +242 -0
  48. agent_lifecycle/contracts/context_model_schemas.py +265 -0
  49. agent_lifecycle/contracts/core_schemas.py +252 -0
  50. agent_lifecycle/contracts/cross_check_schemas.py +121 -0
  51. agent_lifecycle/contracts/errors.py +26 -0
  52. agent_lifecycle/contracts/evidence_import_schemas.py +152 -0
  53. agent_lifecycle/contracts/host_capability_schemas.py +64 -0
  54. agent_lifecycle/contracts/import_dialect_schemas.py +166 -0
  55. agent_lifecycle/contracts/metric_schemas.py +315 -0
  56. agent_lifecycle/contracts/paths.py +31 -0
  57. agent_lifecycle/contracts/plan_contract_schemas.py +148 -0
  58. agent_lifecycle/contracts/policy_schemas.py +350 -0
  59. agent_lifecycle/contracts/progress_bridge_schemas.py +114 -0
  60. agent_lifecycle/contracts/progress_hook_schemas.py +119 -0
  61. agent_lifecycle/contracts/proof_integrity_schemas.py +240 -0
  62. agent_lifecycle/contracts/release_contract_schemas.py +476 -0
  63. agent_lifecycle/contracts/review_mesh_recommendation_schemas.py +64 -0
  64. agent_lifecycle/contracts/review_mesh_schemas.py +222 -0
  65. agent_lifecycle/contracts/review_quality_schemas.py +109 -0
  66. agent_lifecycle/contracts/runner_schemas.py +91 -0
  67. agent_lifecycle/contracts/runner_worktree_schemas.py +389 -0
  68. agent_lifecycle/contracts/sandbox_schemas.py +159 -0
  69. agent_lifecycle/contracts/schema_builders.py +23 -0
  70. agent_lifecycle/contracts/schemas.py +93 -0
  71. agent_lifecycle/contracts/status_goal_schemas.py +469 -0
  72. agent_lifecycle/contracts/task_template_schemas.py +96 -0
  73. agent_lifecycle/contracts/usage_export_schemas.py +68 -0
  74. agent_lifecycle/diagnostics/__init__.py +17 -0
  75. agent_lifecycle/diagnostics/bundles.py +124 -0
  76. agent_lifecycle/diagnostics/readiness.py +733 -0
  77. agent_lifecycle/evidence_index/__init__.py +39 -0
  78. agent_lifecycle/evidence_index/core.py +272 -0
  79. agent_lifecycle/evidence_index/episode_index.py +329 -0
  80. agent_lifecycle/evidence_index/external_context.py +276 -0
  81. agent_lifecycle/followup/__init__.py +23 -0
  82. agent_lifecycle/followup/records.py +404 -0
  83. agent_lifecycle/freeze/__init__.py +5 -0
  84. agent_lifecycle/freeze/locks.py +23 -0
  85. agent_lifecycle/goal/__init__.py +15 -0
  86. agent_lifecycle/goal/records.py +244 -0
  87. agent_lifecycle/goal/view.py +109 -0
  88. agent_lifecycle/host_protocol/__init__.py +65 -0
  89. agent_lifecycle/host_protocol/acp_capability.py +195 -0
  90. agent_lifecycle/host_protocol/capabilities.py +268 -0
  91. agent_lifecycle/host_protocol/contracts.py +214 -0
  92. agent_lifecycle/host_protocol/event_capture.py +390 -0
  93. agent_lifecycle/host_protocol/events.py +196 -0
  94. agent_lifecycle/host_protocol/inspection.py +194 -0
  95. agent_lifecycle/host_protocol/inspection_common.py +310 -0
  96. agent_lifecycle/host_protocol/inspection_cursor.py +164 -0
  97. agent_lifecycle/host_protocol/inspection_gemini_cli.py +163 -0
  98. agent_lifecycle/host_protocol/inspection_hermes.py +161 -0
  99. agent_lifecycle/host_protocol/inspection_kimi_code.py +169 -0
  100. agent_lifecycle/host_protocol/inspection_opencode.py +147 -0
  101. agent_lifecycle/host_protocol/inspection_qwen_code.py +143 -0
  102. agent_lifecycle/host_protocol/receipts.py +58 -0
  103. agent_lifecycle/host_protocol/scaffold.py +329 -0
  104. agent_lifecycle/host_protocol/validation.py +229 -0
  105. agent_lifecycle/imports/__init__.py +76 -0
  106. agent_lifecycle/imports/agentskills_profile.py +54 -0
  107. agent_lifecycle/imports/bmad_profile.py +49 -0
  108. agent_lifecycle/imports/constitution_adr.py +91 -0
  109. agent_lifecycle/imports/dialect_profiles.py +121 -0
  110. agent_lifecycle/imports/external_agent.py +28 -0
  111. agent_lifecycle/imports/external_dialects.py +486 -0
  112. agent_lifecycle/imports/external_workflow.py +28 -0
  113. agent_lifecycle/imports/multi_markdown.py +111 -0
  114. agent_lifecycle/imports/openspec_profile.py +49 -0
  115. agent_lifecycle/imports/planning.py +536 -0
  116. agent_lifecycle/imports/spec_kit_profile.py +49 -0
  117. agent_lifecycle/imports/spec_kitty_profile.py +49 -0
  118. agent_lifecycle/metrics/__init__.py +60 -0
  119. agent_lifecycle/metrics/cost_collection.py +315 -0
  120. agent_lifecycle/metrics/costs.py +203 -0
  121. agent_lifecycle/metrics/outcome_index.py +330 -0
  122. agent_lifecycle/metrics/phase_resources.py +208 -0
  123. agent_lifecycle/metrics/recommendations.py +507 -0
  124. agent_lifecycle/metrics/regression_signals.py +64 -0
  125. agent_lifecycle/metrics/usage_export.py +390 -0
  126. agent_lifecycle/model_routing/__init__.py +19 -0
  127. agent_lifecycle/model_routing/profiles.py +323 -0
  128. agent_lifecycle/model_routing/receipts.py +140 -0
  129. agent_lifecycle/model_routing/resolver.py +566 -0
  130. agent_lifecycle/neutrality/__init__.py +12 -0
  131. agent_lifecycle/neutrality/__main__.py +6 -0
  132. agent_lifecycle/neutrality/authority.py +265 -0
  133. agent_lifecycle/neutrality/canonical.py +28 -0
  134. agent_lifecycle/neutrality/cli.py +294 -0
  135. agent_lifecycle/neutrality/ed25519.py +124 -0
  136. agent_lifecycle/neutrality/errors.py +89 -0
  137. agent_lifecycle/neutrality/gate.py +269 -0
  138. agent_lifecycle/neutrality/paths.py +92 -0
  139. agent_lifecycle/neutrality/policy.py +76 -0
  140. agent_lifecycle/neutrality/receipt.py +135 -0
  141. agent_lifecycle/neutrality/scanner.py +310 -0
  142. agent_lifecycle/neutrality/test_runner.py +116 -0
  143. agent_lifecycle/planning/__init__.py +47 -0
  144. agent_lifecycle/planning/acceptance_markdown.py +159 -0
  145. agent_lifecycle/planning/completeness.py +362 -0
  146. agent_lifecycle/planning/continuity.py +255 -0
  147. agent_lifecycle/planning/templates.py +294 -0
  148. agent_lifecycle/planning/tiering.py +87 -0
  149. agent_lifecycle/planning/validation.py +72 -0
  150. agent_lifecycle/policy/__init__.py +37 -0
  151. agent_lifecycle/policy/adaptive_lifecycle.py +416 -0
  152. agent_lifecycle/policy/apply.py +44 -0
  153. agent_lifecycle/policy/proposals.py +189 -0
  154. agent_lifecycle/policy/quality_floor.py +144 -0
  155. agent_lifecycle/policy/runtime_receipts.py +160 -0
  156. agent_lifecycle/py.typed +1 -0
  157. agent_lifecycle/quality/__init__.py +89 -0
  158. agent_lifecycle/quality/bug_forensics.py +727 -0
  159. agent_lifecycle/quality/bug_forensics_advisor.py +88 -0
  160. agent_lifecycle/quality/bug_forensics_recipes.py +239 -0
  161. agent_lifecycle/quality/cross_check.py +437 -0
  162. agent_lifecycle/quality/failure_classification.py +318 -0
  163. agent_lifecycle/quality/packs.py +265 -0
  164. agent_lifecycle/reporting/__init__.py +37 -0
  165. agent_lifecycle/reporting/change_summary.py +156 -0
  166. agent_lifecycle/reporting/event_feed.py +101 -0
  167. agent_lifecycle/reporting/progress_bridge.py +153 -0
  168. agent_lifecycle/reporting/progress_hooks.py +161 -0
  169. agent_lifecycle/reporting/progress_terminal.py +95 -0
  170. agent_lifecycle/reporting/progress_view.py +284 -0
  171. agent_lifecycle/reporting/status_view.py +125 -0
  172. agent_lifecycle/reporting/usage_export.py +59 -0
  173. agent_lifecycle/review/__init__.py +5 -0
  174. agent_lifecycle/review/validation.py +29 -0
  175. agent_lifecycle/review_mesh/__init__.py +83 -0
  176. agent_lifecycle/review_mesh/assignments.py +160 -0
  177. agent_lifecycle/review_mesh/contracts.py +685 -0
  178. agent_lifecycle/review_mesh/operator_templates.py +320 -0
  179. agent_lifecycle/review_mesh/quorum.py +38 -0
  180. agent_lifecycle/review_mesh/recommendation.py +487 -0
  181. agent_lifecycle/review_mesh/results.py +98 -0
  182. agent_lifecycle/review_mesh/synthesis.py +102 -0
  183. agent_lifecycle/runner/__init__.py +47 -0
  184. agent_lifecycle/runner/attempt_snapshots.py +244 -0
  185. agent_lifecycle/runner/core.py +653 -0
  186. agent_lifecycle/runner/sandbox_receipts.py +628 -0
  187. agent_lifecycle/specification/__init__.py +23 -0
  188. agent_lifecycle/specification/completion_check.py +135 -0
  189. agent_lifecycle/specification/completion_gate.py +501 -0
  190. agent_lifecycle/specification/completion_signal.py +75 -0
  191. agent_lifecycle/specification/validation.py +48 -0
  192. agent_lifecycle/workflow/__init__.py +79 -0
  193. agent_lifecycle/workflow/artifacts.py +74 -0
  194. agent_lifecycle/workflow/budget_decisions.py +370 -0
  195. agent_lifecycle/workflow/budget_policy.py +140 -0
  196. agent_lifecycle/workflow/budget_receipts.py +70 -0
  197. agent_lifecycle/workflow/bug_forensics_gates.py +284 -0
  198. agent_lifecycle/workflow/controller.py +45 -0
  199. agent_lifecycle/workflow/events.py +50 -0
  200. agent_lifecycle/workflow/final_proof_integrity.py +74 -0
  201. agent_lifecycle/workflow/finalization.py +422 -0
  202. agent_lifecycle/workflow/gates.py +185 -0
  203. agent_lifecycle/workflow/implementation_audit_gate.py +126 -0
  204. agent_lifecycle/workflow/leases.py +220 -0
  205. agent_lifecycle/workflow/lineage.py +108 -0
  206. agent_lifecycle/workflow/managed_runner.py +167 -0
  207. agent_lifecycle/workflow/model_usage.py +106 -0
  208. agent_lifecycle/workflow/next_action.py +86 -0
  209. agent_lifecycle/workflow/operation_kernel.py +98 -0
  210. agent_lifecycle/workflow/plan_adoption.py +439 -0
  211. agent_lifecycle/workflow/query.py +52 -0
  212. agent_lifecycle/workflow/review_mesh_gate.py +93 -0
  213. agent_lifecycle/workflow/reviews.py +168 -0
  214. agent_lifecycle/workflow/run_transitions.py +163 -0
  215. agent_lifecycle/workflow/sandbox_policy.py +214 -0
  216. agent_lifecycle/workflow/selectors.py +57 -0
  217. agent_lifecycle/workflow/state.py +130 -0
  218. agent_lifecycle/workflow/task_transitions.py +421 -0
  219. agent_lifecycle/worktree/__init__.py +17 -0
  220. agent_lifecycle/worktree/isolation.py +407 -0
  221. agent_lifecycle_kit-1.50.1.dist-info/METADATA +201 -0
  222. agent_lifecycle_kit-1.50.1.dist-info/RECORD +227 -0
  223. agent_lifecycle_kit-1.50.1.dist-info/WHEEL +5 -0
  224. agent_lifecycle_kit-1.50.1.dist-info/entry_points.txt +3 -0
  225. agent_lifecycle_kit-1.50.1.dist-info/licenses/LICENSE +201 -0
  226. agent_lifecycle_kit-1.50.1.dist-info/licenses/NOTICE +5 -0
  227. agent_lifecycle_kit-1.50.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,814 @@
1
+ """Proof-integrity receipts for findings, root causes and final evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from agent_lifecycle.contracts import LifecycleError, canonical_digest
8
+ from agent_lifecycle.contracts.paths import normalize_repo_path
9
+
10
+ FINDING_SCHEMA = "agent-proof-finding.v1"
11
+ ROOT_CAUSE_SCHEMA = "agent-root-cause-evidence.v1"
12
+ FIX_IMPACT_SCHEMA = "agent-fix-impact-receipt.v1"
13
+ HASH_CHAIN_SCHEMA = "agent-receipt-hash-chain.v1"
14
+ HASH_CHAIN_MIGRATION_POLICY_SCHEMA = "agent-hash-chain-migration-policy.v1"
15
+ HASH_CHAIN_MIGRATION_VALIDATION_SCHEMA = "agent-hash-chain-migration-validation.v1"
16
+ PROOF_INTEGRITY_RECEIPT_SCHEMA = "agent-proof-integrity-receipt.v1"
17
+ PROOF_INTEGRITY_VALIDATION_SCHEMA = "agent-proof-integrity-validation.v1"
18
+
19
+ ROOT_CAUSE_STATUSES = {"CONFIRMED", "REJECTED", "INCONCLUSIVE"}
20
+ FIX_IMPACT_STATUSES = {"PASS", "FAIL", "BLOCKED"}
21
+ VALIDATION_STATUSES = {"PASS", "FAIL"}
22
+ DEFAULT_LEGACY_EXEMPTIONS = ("pre-chain-release", "external-archive", "artifact-unavailable")
23
+
24
+
25
+ def stable_finding_id(finding: dict[str, Any], *, namespace: str = "default") -> str:
26
+ """Return a deterministic finding id from normalized finding identity fields."""
27
+
28
+ identity_fields = finding_identity_fields(finding, namespace=namespace)
29
+ return f"finding-{canonical_digest(identity_fields)}"
30
+
31
+
32
+ def finding_identity_fields(finding: dict[str, Any], *, namespace: str = "default") -> dict[str, Any]:
33
+ """Project a finding to fields that should survive retries and line shifts."""
34
+
35
+ if not isinstance(finding, dict):
36
+ raise LifecycleError("invalid-proof-finding", "finding must be an object")
37
+ location = finding.get("location") if isinstance(finding.get("location"), dict) else {}
38
+ raw_path = (
39
+ finding.get("path")
40
+ or finding.get("file")
41
+ or finding.get("sourcePath")
42
+ or location.get("path")
43
+ or location.get("file")
44
+ )
45
+ path = _optional_repo_path(raw_path, label="finding path")
46
+ symbol = _optional_text(
47
+ finding.get("symbol")
48
+ or finding.get("function")
49
+ or finding.get("scope")
50
+ or location.get("symbol")
51
+ or location.get("function")
52
+ )
53
+ rule_id = _optional_text(finding.get("ruleId") or finding.get("rule") or finding.get("code"))
54
+ category = _optional_text(finding.get("category") or finding.get("type") or finding.get("kind"))
55
+ severity = _optional_text(finding.get("severity"))
56
+ message = _optional_text(finding.get("message") or finding.get("summary") or finding.get("title"))
57
+ fields = {
58
+ "namespace": _normalize_text(namespace),
59
+ "ruleId": rule_id,
60
+ "category": category,
61
+ "severity": severity,
62
+ "path": path,
63
+ "symbol": symbol,
64
+ "message": message,
65
+ }
66
+ useful = {key: value for key, value in fields.items() if key != "namespace" and value is not None}
67
+ if not useful:
68
+ raise LifecycleError("invalid-proof-finding", "finding has no stable identity fields")
69
+ return fields
70
+
71
+
72
+ def build_finding_identity(finding: dict[str, Any], *, namespace: str = "default") -> dict[str, Any]:
73
+ """Build a schema-backed stable finding identity."""
74
+
75
+ identity_fields = finding_identity_fields(finding, namespace=namespace)
76
+ finding_digest = canonical_digest(identity_fields)
77
+ body = {
78
+ "schemaVersion": FINDING_SCHEMA,
79
+ "findingId": f"finding-{finding_digest}",
80
+ "findingDigest": finding_digest,
81
+ "identityFields": identity_fields,
82
+ "sourceFindingDigest": canonical_digest(finding),
83
+ "severity": _optional_text(finding.get("severity")),
84
+ "status": _optional_text(finding.get("status")),
85
+ }
86
+ return body
87
+
88
+
89
+ def validate_finding_identity(identity: dict[str, Any]) -> dict[str, Any]:
90
+ blockers: list[dict[str, Any]] = []
91
+ if not isinstance(identity, dict):
92
+ raise LifecycleError("invalid-proof-finding", "finding identity must be an object")
93
+ if identity.get("schemaVersion") != FINDING_SCHEMA:
94
+ blockers.append({"code": "proof-finding-schema-invalid"})
95
+ identity_fields = identity.get("identityFields")
96
+ if not isinstance(identity_fields, dict):
97
+ blockers.append({"code": "proof-finding-fields-invalid"})
98
+ identity_fields = {}
99
+ expected_digest = canonical_digest(identity_fields)
100
+ if identity.get("findingDigest") != expected_digest:
101
+ blockers.append({"code": "proof-finding-digest-mismatch"})
102
+ expected_id = f"finding-{expected_digest}"
103
+ if identity.get("findingId") != expected_id:
104
+ blockers.append({"code": "proof-finding-id-mismatch"})
105
+ _check_digest(identity.get("sourceFindingDigest"), "proof-finding-source-digest", blockers)
106
+ return _validation("agent-proof-finding-validation.v1", blockers, findingId=identity.get("findingId"))
107
+
108
+
109
+ def build_root_cause_evidence(
110
+ *,
111
+ finding_id: str,
112
+ root_cause: dict[str, Any],
113
+ evidence_ids: list[str],
114
+ verifier: dict[str, Any],
115
+ status: str = "CONFIRMED",
116
+ ) -> dict[str, Any]:
117
+ if not isinstance(root_cause, dict):
118
+ raise LifecycleError("invalid-root-cause-evidence", "rootCause must be an object")
119
+ _required_string(finding_id, label="findingId", code="invalid-root-cause-evidence")
120
+ _string_list(evidence_ids, label="evidenceIds", code="invalid-root-cause-evidence", allow_empty=False)
121
+ _verifier(verifier, code="invalid-root-cause-evidence")
122
+ body = {
123
+ "schemaVersion": ROOT_CAUSE_SCHEMA,
124
+ "status": status,
125
+ "findingId": finding_id,
126
+ "rootCause": root_cause,
127
+ "rootCauseDigest": canonical_digest(root_cause),
128
+ "evidenceIds": list(evidence_ids),
129
+ "verifier": dict(verifier),
130
+ "productionPromotionClaimed": False,
131
+ }
132
+ return {**body, "evidenceDigest": canonical_digest(body)}
133
+
134
+
135
+ def validate_root_cause_evidence(evidence: dict[str, Any]) -> dict[str, Any]:
136
+ blockers: list[dict[str, Any]] = []
137
+ if not isinstance(evidence, dict):
138
+ raise LifecycleError("invalid-root-cause-evidence", "root cause evidence must be an object")
139
+ if evidence.get("schemaVersion") != ROOT_CAUSE_SCHEMA:
140
+ blockers.append({"code": "root-cause-schema-invalid"})
141
+ if evidence.get("status") not in ROOT_CAUSE_STATUSES:
142
+ blockers.append({"code": "root-cause-status-invalid", "status": evidence.get("status")})
143
+ if not isinstance(evidence.get("findingId"), str) or not evidence["findingId"]:
144
+ blockers.append({"code": "root-cause-finding-id-missing"})
145
+ root_cause = evidence.get("rootCause")
146
+ if not isinstance(root_cause, dict):
147
+ blockers.append({"code": "root-cause-payload-invalid"})
148
+ root_cause = {}
149
+ if evidence.get("rootCauseDigest") != canonical_digest(root_cause):
150
+ blockers.append({"code": "root-cause-digest-mismatch"})
151
+ _check_string_list(evidence.get("evidenceIds"), "root-cause-evidence-ids", blockers, allow_empty=False)
152
+ _check_verifier(evidence.get("verifier"), "root-cause-verifier", blockers)
153
+ if evidence.get("productionPromotionClaimed") is not False:
154
+ blockers.append({"code": "root-cause-production-claim"})
155
+ expected = canonical_digest(_without_digest(evidence, "evidenceDigest"))
156
+ if evidence.get("evidenceDigest") != expected:
157
+ blockers.append({"code": "root-cause-evidence-digest-mismatch"})
158
+ return _validation(
159
+ "agent-root-cause-evidence-validation.v1",
160
+ blockers,
161
+ rootCauseDigest=evidence.get("rootCauseDigest"),
162
+ )
163
+
164
+
165
+ def build_fix_impact_receipt(
166
+ *,
167
+ lineage: dict[str, Any],
168
+ changed_files: list[str],
169
+ related_finding_ids: list[str],
170
+ root_cause_digests: list[str],
171
+ behavior_changes: list[dict[str, Any]],
172
+ preserved_behaviors: list[dict[str, Any]],
173
+ validation_evidence_ids: list[str],
174
+ collateral_damage: dict[str, Any],
175
+ verifier: dict[str, Any],
176
+ status: str = "PASS",
177
+ ) -> dict[str, Any]:
178
+ normalized_files = [normalize_repo_path(path, label="changedFiles") for path in changed_files]
179
+ _lineage(lineage)
180
+ _string_list(related_finding_ids, label="relatedFindingIds", code="invalid-fix-impact-receipt", allow_empty=False)
181
+ _digest_list(root_cause_digests, label="rootCauseDigests", code="invalid-fix-impact-receipt")
182
+ _object_list(behavior_changes, label="behaviorChanges", code="invalid-fix-impact-receipt", allow_empty=False)
183
+ _object_list(preserved_behaviors, label="preservedBehaviors", code="invalid-fix-impact-receipt", allow_empty=False)
184
+ _string_list(validation_evidence_ids, label="validationEvidenceIds", code="invalid-fix-impact-receipt", allow_empty=False)
185
+ if not isinstance(collateral_damage, dict):
186
+ raise LifecycleError("invalid-fix-impact-receipt", "collateralDamage must be an object")
187
+ _verifier(verifier, code="invalid-fix-impact-receipt")
188
+ body = {
189
+ "schemaVersion": FIX_IMPACT_SCHEMA,
190
+ "status": status,
191
+ "lineage": dict(lineage),
192
+ "changedFiles": normalized_files,
193
+ "relatedFindingIds": list(related_finding_ids),
194
+ "rootCauseDigests": list(root_cause_digests),
195
+ "behaviorChanges": list(behavior_changes),
196
+ "preservedBehaviors": list(preserved_behaviors),
197
+ "validationEvidenceIds": list(validation_evidence_ids),
198
+ "collateralDamage": dict(collateral_damage),
199
+ "verifier": dict(verifier),
200
+ "productionPromotionClaimed": False,
201
+ }
202
+ return {**body, "impactDigest": canonical_digest(body)}
203
+
204
+
205
+ def validate_fix_impact_receipt(receipt: dict[str, Any]) -> dict[str, Any]:
206
+ blockers: list[dict[str, Any]] = []
207
+ if not isinstance(receipt, dict):
208
+ raise LifecycleError("invalid-fix-impact-receipt", "fix impact receipt must be an object")
209
+ if receipt.get("schemaVersion") != FIX_IMPACT_SCHEMA:
210
+ blockers.append({"code": "fix-impact-schema-invalid"})
211
+ if receipt.get("status") not in FIX_IMPACT_STATUSES:
212
+ blockers.append({"code": "fix-impact-status-invalid", "status": receipt.get("status")})
213
+ _check_lineage(receipt.get("lineage"), blockers, code="fix-impact-lineage")
214
+ _check_changed_files(receipt.get("changedFiles"), blockers)
215
+ _check_string_list(receipt.get("relatedFindingIds"), "fix-impact-finding-ids", blockers, allow_empty=False)
216
+ _check_digest_list(receipt.get("rootCauseDigests"), "fix-impact-root-cause-digests", blockers)
217
+ _check_object_list(receipt.get("behaviorChanges"), "fix-impact-behavior-changes", blockers, allow_empty=False)
218
+ _check_object_list(receipt.get("preservedBehaviors"), "fix-impact-preserved-behaviors", blockers, allow_empty=False)
219
+ _check_string_list(receipt.get("validationEvidenceIds"), "fix-impact-validation-evidence", blockers, allow_empty=False)
220
+ collateral = receipt.get("collateralDamage")
221
+ if not isinstance(collateral, dict):
222
+ blockers.append({"code": "fix-impact-collateral-damage-invalid"})
223
+ elif collateral.get("status") not in {"PASS", "WAIVED"}:
224
+ blockers.append({"code": "fix-impact-collateral-damage-status", "status": collateral.get("status")})
225
+ _check_verifier(receipt.get("verifier"), "fix-impact-verifier", blockers)
226
+ if receipt.get("productionPromotionClaimed") is not False:
227
+ blockers.append({"code": "fix-impact-production-claim"})
228
+ expected = canonical_digest(_without_digest(receipt, "impactDigest"))
229
+ if receipt.get("impactDigest") != expected:
230
+ blockers.append({"code": "fix-impact-digest-mismatch"})
231
+ return _validation("agent-fix-impact-receipt-validation.v1", blockers, impactDigest=receipt.get("impactDigest"))
232
+
233
+
234
+ def build_receipt_hash_chain(
235
+ entries: list[dict[str, Any]],
236
+ *,
237
+ chain_id: str,
238
+ lineage: dict[str, Any] | None = None,
239
+ ) -> dict[str, Any]:
240
+ _required_string(chain_id, label="chainId", code="invalid-receipt-hash-chain")
241
+ chain_entries: list[dict[str, Any]] = []
242
+ previous: str | None = None
243
+ for index, item in enumerate(entries, start=1):
244
+ if not isinstance(item, dict):
245
+ raise LifecycleError("invalid-receipt-hash-chain", "chain entries must be objects")
246
+ artifact = _chain_artifact(item)
247
+ entry_body = {
248
+ "sequence": index,
249
+ "artifact": artifact,
250
+ "previousEntryHash": previous,
251
+ "operationId": _optional_text(item.get("operationId")),
252
+ "evidenceIds": _optional_string_list(item.get("evidenceIds")),
253
+ }
254
+ entry = {**entry_body, "entryHash": canonical_digest(entry_body)}
255
+ chain_entries.append(entry)
256
+ previous = entry["entryHash"]
257
+ body = {
258
+ "schemaVersion": HASH_CHAIN_SCHEMA,
259
+ "status": "PASS",
260
+ "chainId": chain_id,
261
+ "appendOnly": True,
262
+ "lineage": dict(lineage or {}),
263
+ "entries": chain_entries,
264
+ "blockers": [],
265
+ "productionPromotionClaimed": False,
266
+ }
267
+ if not chain_entries:
268
+ body["status"] = "FAIL"
269
+ body["blockers"].append({"code": "receipt-hash-chain-empty"})
270
+ return {**body, "chainDigest": canonical_digest(body)}
271
+
272
+
273
+ def validate_receipt_hash_chain(chain: dict[str, Any]) -> dict[str, Any]:
274
+ blockers: list[dict[str, Any]] = []
275
+ if not isinstance(chain, dict):
276
+ raise LifecycleError("invalid-receipt-hash-chain", "receipt hash chain must be an object")
277
+ if chain.get("schemaVersion") != HASH_CHAIN_SCHEMA:
278
+ blockers.append({"code": "receipt-hash-chain-schema-invalid"})
279
+ if chain.get("status") != "PASS":
280
+ blockers.append({"code": "receipt-hash-chain-status-not-pass", "status": chain.get("status")})
281
+ if not isinstance(chain.get("chainId"), str) or not chain["chainId"]:
282
+ blockers.append({"code": "receipt-hash-chain-id-missing"})
283
+ if chain.get("appendOnly") is not True:
284
+ blockers.append({"code": "receipt-hash-chain-not-append-only"})
285
+ entries = chain.get("entries")
286
+ if not isinstance(entries, list) or not entries:
287
+ blockers.append({"code": "receipt-hash-chain-entries-missing"})
288
+ entries = []
289
+ previous: str | None = None
290
+ for expected_sequence, entry in enumerate(entries, start=1):
291
+ if not isinstance(entry, dict):
292
+ blockers.append({"code": "receipt-hash-chain-entry-invalid", "sequence": expected_sequence})
293
+ continue
294
+ _validate_chain_entry(entry, expected_sequence, previous, blockers)
295
+ previous = entry.get("entryHash") if _is_digest(entry.get("entryHash")) else previous
296
+ if chain.get("productionPromotionClaimed") is not False:
297
+ blockers.append({"code": "receipt-hash-chain-production-claim"})
298
+ if chain.get("blockers"):
299
+ blockers.append({"code": "receipt-hash-chain-has-blockers", "blockers": chain.get("blockers")})
300
+ expected = canonical_digest(_without_digest(chain, "chainDigest"))
301
+ if chain.get("chainDigest") != expected:
302
+ blockers.append({"code": "receipt-hash-chain-digest-mismatch"})
303
+ return _validation(
304
+ "agent-receipt-hash-chain-validation.v1",
305
+ blockers,
306
+ chainDigest=chain.get("chainDigest"),
307
+ chainStatus=chain.get("status"),
308
+ entryCount=len(entries),
309
+ )
310
+
311
+
312
+ def build_hash_chain_migration_policy(
313
+ *,
314
+ allowed_legacy_exemptions: list[str] | None = None,
315
+ backfill_behavior: str = "backfill-hash-chain-when-artifacts-are-available",
316
+ ) -> dict[str, Any]:
317
+ exemptions = list(allowed_legacy_exemptions or DEFAULT_LEGACY_EXEMPTIONS)
318
+ body = {
319
+ "schemaVersion": HASH_CHAIN_MIGRATION_POLICY_SCHEMA,
320
+ "status": "PASS",
321
+ "mode": "required-for-new-runs",
322
+ "newRunsRequireChain": True,
323
+ "legacyReceiptPolicy": "explicit-exemption-or-backfill",
324
+ "allowedLegacyExemptions": exemptions,
325
+ "backfillBehavior": backfill_behavior,
326
+ "productionPromotionClaimed": False,
327
+ }
328
+ return {**body, "policyDigest": canonical_digest(body)}
329
+
330
+
331
+ def validate_hash_chain_migration_policy(
332
+ policy: dict[str, Any],
333
+ *,
334
+ new_run: bool,
335
+ hash_chain: dict[str, Any] | None,
336
+ legacy_exemption: dict[str, Any] | None = None,
337
+ ) -> dict[str, Any]:
338
+ blockers: list[dict[str, Any]] = []
339
+ if not isinstance(policy, dict):
340
+ raise LifecycleError("invalid-hash-chain-migration-policy", "hash chain migration policy must be an object")
341
+ if policy.get("schemaVersion") != HASH_CHAIN_MIGRATION_POLICY_SCHEMA:
342
+ blockers.append({"code": "hash-chain-policy-schema-invalid"})
343
+ if policy.get("status") != "PASS":
344
+ blockers.append({"code": "hash-chain-policy-status-not-pass", "status": policy.get("status")})
345
+ if policy.get("mode") != "required-for-new-runs":
346
+ blockers.append({"code": "hash-chain-policy-mode-invalid"})
347
+ if policy.get("newRunsRequireChain") is not True:
348
+ blockers.append({"code": "hash-chain-policy-new-runs-not-required"})
349
+ if policy.get("legacyReceiptPolicy") != "explicit-exemption-or-backfill":
350
+ blockers.append({"code": "hash-chain-policy-legacy-invalid"})
351
+ exemptions = policy.get("allowedLegacyExemptions")
352
+ if not isinstance(exemptions, list) or not exemptions or not all(isinstance(item, str) and item for item in exemptions):
353
+ blockers.append({"code": "hash-chain-policy-exemptions-invalid"})
354
+ exemptions = []
355
+ if not isinstance(policy.get("backfillBehavior"), str) or not policy["backfillBehavior"]:
356
+ blockers.append({"code": "hash-chain-policy-backfill-missing"})
357
+ if policy.get("productionPromotionClaimed") is not False:
358
+ blockers.append({"code": "hash-chain-policy-production-claim"})
359
+ expected = canonical_digest(_without_digest(policy, "policyDigest"))
360
+ if policy.get("policyDigest") != expected:
361
+ blockers.append({"code": "hash-chain-policy-digest-mismatch"})
362
+ if new_run and hash_chain is None:
363
+ blockers.append({"code": "hash-chain-required-for-new-run"})
364
+ if not new_run and hash_chain is None:
365
+ _validate_legacy_exemption(legacy_exemption, exemptions, blockers)
366
+ body = {
367
+ "schemaVersion": HASH_CHAIN_MIGRATION_VALIDATION_SCHEMA,
368
+ "status": "PASS" if not blockers else "FAIL",
369
+ "newRun": new_run,
370
+ "blockers": blockers,
371
+ "policyDigest": policy.get("policyDigest"),
372
+ }
373
+ return {**body, "validationDigest": canonical_digest(body)}
374
+
375
+
376
+ def build_proof_integrity_receipt(
377
+ *,
378
+ lineage: dict[str, Any],
379
+ findings: list[dict[str, Any]],
380
+ root_causes: list[dict[str, Any]],
381
+ fix_impact_receipts: list[dict[str, Any]],
382
+ hash_chain: dict[str, Any],
383
+ migration_policy: dict[str, Any] | None = None,
384
+ required_evidence_ids: list[str] | None = None,
385
+ verifier: dict[str, Any],
386
+ ) -> dict[str, Any]:
387
+ _lineage(lineage)
388
+ _verifier(verifier, code="invalid-proof-integrity-receipt")
389
+ body = {
390
+ "schemaVersion": PROOF_INTEGRITY_RECEIPT_SCHEMA,
391
+ "status": "PASS",
392
+ "lineage": dict(lineage),
393
+ "requiredEvidenceIds": list(required_evidence_ids or []),
394
+ "findings": list(findings),
395
+ "rootCauses": list(root_causes),
396
+ "fixImpactReceipts": list(fix_impact_receipts),
397
+ "hashChain": dict(hash_chain),
398
+ "migrationPolicy": dict(migration_policy or build_hash_chain_migration_policy()),
399
+ "blockers": [],
400
+ "verifier": dict(verifier),
401
+ "productionPromotionClaimed": False,
402
+ }
403
+ return {**body, "receiptDigest": canonical_digest(body)}
404
+
405
+
406
+ def validate_proof_integrity_receipt(
407
+ receipt: dict[str, Any],
408
+ *,
409
+ state: dict[str, Any] | None = None,
410
+ final_audit: dict[str, Any] | None = None,
411
+ final_proof: dict[str, Any] | None = None,
412
+ require_hash_chain: bool = True,
413
+ new_run: bool = True,
414
+ ) -> dict[str, Any]:
415
+ blockers: list[dict[str, Any]] = []
416
+ if not isinstance(receipt, dict):
417
+ raise LifecycleError("invalid-proof-integrity-receipt", "proof integrity receipt must be an object")
418
+ if receipt.get("schemaVersion") != PROOF_INTEGRITY_RECEIPT_SCHEMA:
419
+ blockers.append({"code": "proof-integrity-schema-invalid"})
420
+ if receipt.get("status") != "PASS":
421
+ blockers.append({"code": "proof-integrity-status-not-pass", "status": receipt.get("status")})
422
+ if receipt.get("productionPromotionClaimed") is not False:
423
+ blockers.append({"code": "proof-integrity-production-claim"})
424
+ _check_lineage(receipt.get("lineage"), blockers, code="proof-integrity-lineage")
425
+ _compare_lineage(receipt.get("lineage"), state, final_audit, final_proof, blockers)
426
+ findings = _object_items(receipt.get("findings"), "proof-integrity-findings", blockers)
427
+ root_causes = _object_items(receipt.get("rootCauses"), "proof-integrity-root-causes", blockers)
428
+ fix_impacts = _object_items(receipt.get("fixImpactReceipts"), "proof-integrity-fix-impacts", blockers)
429
+ finding_ids = _validate_findings(findings, blockers)
430
+ root_cause_digests = _validate_root_causes(root_causes, finding_ids, blockers)
431
+ _validate_fix_impacts(fix_impacts, finding_ids, root_cause_digests, blockers)
432
+ _validate_required_sets(receipt, final_audit, finding_ids, root_cause_digests, fix_impacts, blockers)
433
+ hash_chain = receipt.get("hashChain")
434
+ chain_status: str | None = None
435
+ if require_hash_chain and not isinstance(hash_chain, dict):
436
+ blockers.append({"code": "proof-integrity-hash-chain-missing"})
437
+ hash_chain = None
438
+ if isinstance(hash_chain, dict):
439
+ chain_validation = validate_receipt_hash_chain(hash_chain)
440
+ chain_status = chain_validation["status"]
441
+ if chain_validation["status"] != "PASS":
442
+ blockers.append({"code": "proof-integrity-hash-chain-invalid", "validation": chain_validation})
443
+ policy = receipt.get("migrationPolicy")
444
+ if not isinstance(policy, dict):
445
+ blockers.append({"code": "proof-integrity-migration-policy-missing"})
446
+ else:
447
+ migration_validation = validate_hash_chain_migration_policy(
448
+ policy,
449
+ new_run=new_run,
450
+ hash_chain=hash_chain if isinstance(hash_chain, dict) and chain_status == "PASS" else None,
451
+ legacy_exemption=receipt.get("legacyHashChainExemption"),
452
+ )
453
+ if migration_validation["status"] != "PASS":
454
+ blockers.append({"code": "proof-integrity-migration-policy-invalid", "validation": migration_validation})
455
+ if receipt.get("blockers"):
456
+ blockers.append({"code": "proof-integrity-receipt-has-blockers", "blockers": receipt.get("blockers")})
457
+ _check_verifier(receipt.get("verifier"), "proof-integrity-verifier", blockers)
458
+ expected = canonical_digest(_without_digest(receipt, "receiptDigest"))
459
+ if receipt.get("receiptDigest") != expected:
460
+ blockers.append({"code": "proof-integrity-receipt-digest-mismatch"})
461
+ body = {
462
+ "schemaVersion": PROOF_INTEGRITY_VALIDATION_SCHEMA,
463
+ "status": "PASS" if not blockers else "FAIL",
464
+ "findingCount": len(findings),
465
+ "rootCauseCount": len(root_causes),
466
+ "fixImpactCount": len(fix_impacts),
467
+ "chainStatus": chain_status,
468
+ "blockers": blockers,
469
+ "receiptDigest": receipt.get("receiptDigest"),
470
+ }
471
+ return {**body, "validationDigest": canonical_digest(body)}
472
+
473
+
474
+ def require_proof_integrity_pass(validation: dict[str, Any]) -> dict[str, Any]:
475
+ if validation.get("status") != "PASS":
476
+ raise LifecycleError("proof-integrity-validation-failed", "proof integrity validation failed", {"validation": validation})
477
+ return validation
478
+
479
+
480
+ def _validate_findings(findings: list[dict[str, Any]], blockers: list[dict[str, Any]]) -> set[str]:
481
+ finding_ids: set[str] = set()
482
+ for index, finding in enumerate(findings):
483
+ validation = validate_finding_identity(finding)
484
+ if validation["status"] != "PASS":
485
+ blockers.append({"code": "proof-integrity-finding-invalid", "index": index, "validation": validation})
486
+ finding_id = finding.get("findingId")
487
+ if isinstance(finding_id, str) and finding_id:
488
+ if finding_id in finding_ids:
489
+ blockers.append({"code": "proof-integrity-finding-duplicate", "findingId": finding_id})
490
+ finding_ids.add(finding_id)
491
+ return finding_ids
492
+
493
+
494
+ def _validate_root_causes(
495
+ root_causes: list[dict[str, Any]],
496
+ finding_ids: set[str],
497
+ blockers: list[dict[str, Any]],
498
+ ) -> set[str]:
499
+ root_cause_digests: set[str] = set()
500
+ root_causes_by_finding: set[str] = set()
501
+ for index, evidence in enumerate(root_causes):
502
+ validation = validate_root_cause_evidence(evidence)
503
+ if validation["status"] != "PASS":
504
+ blockers.append({"code": "proof-integrity-root-cause-invalid", "index": index, "validation": validation})
505
+ if evidence.get("status") != "CONFIRMED":
506
+ blockers.append({"code": "proof-integrity-root-cause-not-confirmed", "index": index, "status": evidence.get("status")})
507
+ finding_id = evidence.get("findingId")
508
+ if isinstance(finding_id, str) and finding_id:
509
+ root_causes_by_finding.add(finding_id)
510
+ if finding_ids and finding_id not in finding_ids:
511
+ blockers.append({"code": "proof-integrity-root-cause-unmatched-finding", "findingId": finding_id})
512
+ digest = evidence.get("rootCauseDigest")
513
+ if _is_digest(digest):
514
+ root_cause_digests.add(str(digest))
515
+ for finding_id in sorted(finding_ids.difference(root_causes_by_finding)):
516
+ blockers.append({"code": "proof-integrity-root-cause-missing", "findingId": finding_id})
517
+ return root_cause_digests
518
+
519
+
520
+ def _validate_fix_impacts(
521
+ fix_impacts: list[dict[str, Any]],
522
+ finding_ids: set[str],
523
+ root_cause_digests: set[str],
524
+ blockers: list[dict[str, Any]],
525
+ ) -> None:
526
+ covered_findings: set[str] = set()
527
+ covered_root_causes: set[str] = set()
528
+ for index, receipt in enumerate(fix_impacts):
529
+ validation = validate_fix_impact_receipt(receipt)
530
+ if validation["status"] != "PASS":
531
+ blockers.append({"code": "proof-integrity-fix-impact-invalid", "index": index, "validation": validation})
532
+ if receipt.get("status") != "PASS":
533
+ blockers.append({"code": "proof-integrity-fix-impact-not-pass", "index": index, "status": receipt.get("status")})
534
+ for finding_id in receipt.get("relatedFindingIds") or []:
535
+ if isinstance(finding_id, str):
536
+ covered_findings.add(finding_id)
537
+ if finding_ids and finding_id not in finding_ids:
538
+ blockers.append({"code": "proof-integrity-fix-impact-unmatched-finding", "findingId": finding_id})
539
+ for digest in receipt.get("rootCauseDigests") or []:
540
+ if isinstance(digest, str):
541
+ covered_root_causes.add(digest)
542
+ if root_cause_digests and digest not in root_cause_digests:
543
+ blockers.append({"code": "proof-integrity-fix-impact-unmatched-root-cause", "rootCauseDigest": digest})
544
+ for finding_id in sorted(finding_ids.difference(covered_findings)):
545
+ blockers.append({"code": "proof-integrity-fix-impact-missing", "findingId": finding_id})
546
+ for digest in sorted(root_cause_digests.difference(covered_root_causes)):
547
+ blockers.append({"code": "proof-integrity-root-cause-fix-impact-missing", "rootCauseDigest": digest})
548
+
549
+
550
+ def _validate_required_sets(
551
+ receipt: dict[str, Any],
552
+ final_audit: dict[str, Any] | None,
553
+ finding_ids: set[str],
554
+ root_cause_digests: set[str],
555
+ fix_impacts: list[dict[str, Any]],
556
+ blockers: list[dict[str, Any]],
557
+ ) -> None:
558
+ requirements = final_audit.get("proofIntegrityEvidence") if isinstance(final_audit, dict) else None
559
+ if not isinstance(requirements, dict):
560
+ return
561
+ required_findings = set(_optional_string_list(requirements.get("requiredFindingIds")))
562
+ required_root_causes = set(_optional_string_list(requirements.get("requiredRootCauseDigests")))
563
+ required_fix_impacts = set(_optional_string_list(requirements.get("requiredFixImpactDigests")))
564
+ fix_impact_digests = {str(item.get("impactDigest")) for item in fix_impacts if _is_digest(item.get("impactDigest"))}
565
+ for finding_id in sorted(required_findings.difference(finding_ids)):
566
+ blockers.append({"code": "proof-integrity-required-finding-missing", "findingId": finding_id})
567
+ for digest in sorted(required_root_causes.difference(root_cause_digests)):
568
+ blockers.append({"code": "proof-integrity-required-root-cause-missing", "rootCauseDigest": digest})
569
+ for digest in sorted(required_fix_impacts.difference(fix_impact_digests)):
570
+ blockers.append({"code": "proof-integrity-required-fix-impact-missing", "impactDigest": digest})
571
+ required_evidence_ids = set(_optional_string_list(requirements.get("requiredEvidenceIds")))
572
+ receipt_evidence_ids = set(_optional_string_list(receipt.get("requiredEvidenceIds")))
573
+ for evidence_id in sorted(required_evidence_ids.difference(receipt_evidence_ids)):
574
+ blockers.append({"code": "proof-integrity-required-evidence-id-missing", "evidenceId": evidence_id})
575
+
576
+
577
+ def _validate_chain_entry(
578
+ entry: dict[str, Any],
579
+ expected_sequence: int,
580
+ expected_previous: str | None,
581
+ blockers: list[dict[str, Any]],
582
+ ) -> None:
583
+ if entry.get("sequence") != expected_sequence:
584
+ blockers.append({"code": "receipt-hash-chain-sequence-mismatch", "sequence": expected_sequence})
585
+ artifact = entry.get("artifact")
586
+ if not isinstance(artifact, dict):
587
+ blockers.append({"code": "receipt-hash-chain-artifact-invalid", "sequence": expected_sequence})
588
+ else:
589
+ try:
590
+ normalize_repo_path(str(artifact.get("path")), label="chain artifact path")
591
+ except LifecycleError as exc:
592
+ blockers.append({"code": "receipt-hash-chain-artifact-path-invalid", "sequence": expected_sequence, "reason": exc.code})
593
+ _check_digest(artifact.get("digest"), "receipt-hash-chain-artifact-digest-invalid", blockers)
594
+ if entry.get("previousEntryHash") != expected_previous:
595
+ blockers.append({"code": "receipt-hash-chain-previous-mismatch", "sequence": expected_sequence})
596
+ expected_hash = canonical_digest(_without_digest(entry, "entryHash"))
597
+ if entry.get("entryHash") != expected_hash:
598
+ blockers.append({"code": "receipt-hash-chain-entry-hash-mismatch", "sequence": expected_sequence})
599
+
600
+
601
+ def _chain_artifact(item: dict[str, Any]) -> dict[str, Any]:
602
+ raw_artifact = item.get("artifact") if isinstance(item.get("artifact"), dict) else item
603
+ path = normalize_repo_path(str(raw_artifact.get("path") or raw_artifact.get("sourcePath")), label="chain artifact path")
604
+ digest = raw_artifact.get("digest") or raw_artifact.get("artifactDigest") or raw_artifact.get("sha256")
605
+ if not _is_digest(digest):
606
+ raise LifecycleError("invalid-receipt-hash-chain", "chain artifact digest must be a 64-character hex digest")
607
+ artifact = {"path": path, "digest": str(digest)}
608
+ schema_version = raw_artifact.get("schemaVersion") or item.get("artifactType")
609
+ if isinstance(schema_version, str) and schema_version:
610
+ artifact["schemaVersion"] = schema_version
611
+ return artifact
612
+
613
+
614
+ def _compare_lineage(
615
+ lineage: Any,
616
+ state: dict[str, Any] | None,
617
+ final_audit: dict[str, Any] | None,
618
+ final_proof: dict[str, Any] | None,
619
+ blockers: list[dict[str, Any]],
620
+ ) -> None:
621
+ if not isinstance(lineage, dict):
622
+ return
623
+ for label, artifact in (("state", state), ("final-audit", final_audit), ("final-proof", final_proof)):
624
+ if not isinstance(artifact, dict):
625
+ continue
626
+ for key in ("runId", "packageId", "planRevision", "planDigest", "sourceRevision"):
627
+ if key in artifact and lineage.get(key) != artifact.get(key):
628
+ blockers.append({"code": "proof-integrity-lineage-mismatch", "artifact": label, "field": key})
629
+
630
+
631
+ def _validate_legacy_exemption(
632
+ exemption: dict[str, Any] | None,
633
+ allowed_reasons: list[Any],
634
+ blockers: list[dict[str, Any]],
635
+ ) -> None:
636
+ if not isinstance(exemption, dict):
637
+ blockers.append({"code": "hash-chain-legacy-exemption-required"})
638
+ return
639
+ reason = exemption.get("reason")
640
+ if reason not in allowed_reasons:
641
+ blockers.append({"code": "hash-chain-legacy-exemption-reason-invalid", "reason": reason})
642
+ if not isinstance(exemption.get("approvedBy"), str) or not exemption["approvedBy"]:
643
+ blockers.append({"code": "hash-chain-legacy-exemption-approval-missing"})
644
+
645
+
646
+ def _validation(schema: str, blockers: list[dict[str, Any]], **extra: Any) -> dict[str, Any]:
647
+ body = {
648
+ "schemaVersion": schema,
649
+ "status": "PASS" if not blockers else "FAIL",
650
+ "blockers": blockers,
651
+ **extra,
652
+ }
653
+ return {**body, "validationDigest": canonical_digest(body)}
654
+
655
+
656
+ def _lineage(lineage: dict[str, Any]) -> None:
657
+ if not isinstance(lineage, dict):
658
+ raise LifecycleError("invalid-lineage", "lineage must be an object")
659
+ missing = [key for key in ("runId", "packageId", "planRevision", "planDigest", "sourceRevision") if key not in lineage]
660
+ if missing:
661
+ raise LifecycleError("invalid-lineage", "lineage is missing required fields", {"fields": missing})
662
+ if not _is_digest(lineage.get("planDigest")):
663
+ raise LifecycleError("invalid-lineage", "lineage.planDigest must be a 64-character digest")
664
+
665
+
666
+ def _check_lineage(lineage: Any, blockers: list[dict[str, Any]], *, code: str) -> None:
667
+ if not isinstance(lineage, dict):
668
+ blockers.append({"code": f"{code}-invalid"})
669
+ return
670
+ for key in ("runId", "packageId", "sourceRevision"):
671
+ if not isinstance(lineage.get(key), str) or not lineage[key]:
672
+ blockers.append({"code": f"{code}-{key}-missing"})
673
+ if not isinstance(lineage.get("planRevision"), int) or lineage["planRevision"] < 1:
674
+ blockers.append({"code": f"{code}-plan-revision-invalid"})
675
+ _check_digest(lineage.get("planDigest"), f"{code}-plan-digest-invalid", blockers)
676
+
677
+
678
+ def _object_items(value: Any, code: str, blockers: list[dict[str, Any]]) -> list[dict[str, Any]]:
679
+ if not isinstance(value, list):
680
+ blockers.append({"code": f"{code}-invalid"})
681
+ return []
682
+ items: list[dict[str, Any]] = []
683
+ for index, item in enumerate(value):
684
+ if not isinstance(item, dict):
685
+ blockers.append({"code": f"{code}-item-invalid", "index": index})
686
+ else:
687
+ items.append(item)
688
+ return items
689
+
690
+
691
+ def _object_list(value: list[dict[str, Any]], *, label: str, code: str, allow_empty: bool) -> None:
692
+ if not isinstance(value, list) or (not allow_empty and not value):
693
+ raise LifecycleError(code, f"{label} must be a non-empty array")
694
+ if not all(isinstance(item, dict) for item in value):
695
+ raise LifecycleError(code, f"{label} entries must be objects")
696
+
697
+
698
+ def _check_object_list(value: Any, code: str, blockers: list[dict[str, Any]], *, allow_empty: bool) -> None:
699
+ if not isinstance(value, list) or (not allow_empty and not value):
700
+ blockers.append({"code": code})
701
+ return
702
+ if not all(isinstance(item, dict) for item in value):
703
+ blockers.append({"code": f"{code}-item-invalid"})
704
+
705
+
706
+ def _string_list(value: list[str], *, label: str, code: str, allow_empty: bool) -> None:
707
+ if not isinstance(value, list) or (not allow_empty and not value):
708
+ raise LifecycleError(code, f"{label} must be a non-empty array")
709
+ if not all(isinstance(item, str) and item for item in value):
710
+ raise LifecycleError(code, f"{label} entries must be non-empty strings")
711
+
712
+
713
+ def _check_string_list(value: Any, code: str, blockers: list[dict[str, Any]], *, allow_empty: bool) -> None:
714
+ if not isinstance(value, list) or (not allow_empty and not value):
715
+ blockers.append({"code": code})
716
+ return
717
+ if not all(isinstance(item, str) and item for item in value):
718
+ blockers.append({"code": f"{code}-item-invalid"})
719
+
720
+
721
+ def _optional_string_list(value: Any) -> list[str]:
722
+ if not isinstance(value, list):
723
+ return []
724
+ return [item for item in value if isinstance(item, str) and item]
725
+
726
+
727
+ def _digest_list(value: list[str], *, label: str, code: str) -> None:
728
+ if not isinstance(value, list) or not value:
729
+ raise LifecycleError(code, f"{label} must be a non-empty array")
730
+ if not all(_is_digest(item) for item in value):
731
+ raise LifecycleError(code, f"{label} entries must be 64-character digests")
732
+
733
+
734
+ def _check_digest_list(value: Any, code: str, blockers: list[dict[str, Any]]) -> None:
735
+ if not isinstance(value, list) or not value:
736
+ blockers.append({"code": code})
737
+ return
738
+ if not all(_is_digest(item) for item in value):
739
+ blockers.append({"code": f"{code}-item-invalid"})
740
+
741
+
742
+ def _check_changed_files(value: Any, blockers: list[dict[str, Any]]) -> None:
743
+ if not isinstance(value, list) or not value:
744
+ blockers.append({"code": "fix-impact-changed-files-missing"})
745
+ return
746
+ for index, path in enumerate(value):
747
+ try:
748
+ normalize_repo_path(str(path), label="changedFiles")
749
+ except LifecycleError as exc:
750
+ blockers.append({"code": "fix-impact-changed-file-invalid", "index": index, "reason": exc.code})
751
+
752
+
753
+ def _verifier(verifier: dict[str, Any], *, code: str) -> None:
754
+ if not isinstance(verifier, dict) or not isinstance(verifier.get("id"), str) or not verifier["id"]:
755
+ raise LifecycleError(code, "verifier.id is required")
756
+
757
+
758
+ def _check_verifier(value: Any, code: str, blockers: list[dict[str, Any]]) -> None:
759
+ if not isinstance(value, dict) or not isinstance(value.get("id"), str) or not value["id"]:
760
+ blockers.append({"code": code})
761
+
762
+
763
+ def _required_string(value: str, *, label: str, code: str) -> None:
764
+ if not isinstance(value, str) or not value:
765
+ raise LifecycleError(code, f"{label} is required")
766
+
767
+
768
+ def _optional_repo_path(value: Any, *, label: str) -> str | None:
769
+ if value is None:
770
+ return None
771
+ return normalize_repo_path(str(value), label=label)
772
+
773
+
774
+ def _optional_text(value: Any) -> str | None:
775
+ if value is None:
776
+ return None
777
+ text = _normalize_text(str(value))
778
+ return text or None
779
+
780
+
781
+ def _normalize_text(value: str) -> str:
782
+ return " ".join(value.strip().casefold().split())
783
+
784
+
785
+ def _is_digest(value: Any) -> bool:
786
+ return isinstance(value, str) and len(value) == 64 and all(char in "0123456789abcdef" for char in value)
787
+
788
+
789
+ def _check_digest(value: Any, code: str, blockers: list[dict[str, Any]]) -> None:
790
+ if not _is_digest(value):
791
+ blockers.append({"code": code})
792
+
793
+
794
+ def _without_digest(value: dict[str, Any], digest_key: str) -> dict[str, Any]:
795
+ return {key: item for key, item in value.items() if key != digest_key}
796
+
797
+
798
+ __all__ = [
799
+ "build_finding_identity",
800
+ "build_fix_impact_receipt",
801
+ "build_hash_chain_migration_policy",
802
+ "build_proof_integrity_receipt",
803
+ "build_receipt_hash_chain",
804
+ "build_root_cause_evidence",
805
+ "finding_identity_fields",
806
+ "require_proof_integrity_pass",
807
+ "stable_finding_id",
808
+ "validate_finding_identity",
809
+ "validate_fix_impact_receipt",
810
+ "validate_hash_chain_migration_policy",
811
+ "validate_proof_integrity_receipt",
812
+ "validate_receipt_hash_chain",
813
+ "validate_root_cause_evidence",
814
+ ]