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,323 @@
1
+ """Validation helpers for provider-neutral model routing profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from agent_lifecycle.context.profiles import WINDOWS
9
+ from agent_lifecycle.contracts import LifecycleError, canonical_digest, read_json_object
10
+
11
+ ALLOWED_MODEL_CLASSES = {
12
+ "no-model",
13
+ "budget",
14
+ "local-compact",
15
+ "standard-code",
16
+ "local-standard-code",
17
+ "strong-reasoning",
18
+ "local-strong-review",
19
+ "specialist-review",
20
+ }
21
+ LOCAL_MODEL_CLASSES = {"local-compact", "local-standard-code", "local-strong-review"}
22
+ CRITICAL_REVIEW_CLASSES = {"strong-reasoning", "local-strong-review", "specialist-review"}
23
+ ROUTING_POLICIES = {"cost-optimized", "balanced", "quality-first", "local-only", "manual"}
24
+ SDD_TIERS = {"S0", "S1", "S2"}
25
+ JSON_RELIABILITY = {"strict", "best-effort", "unsupported"}
26
+ TOOL_USE = {"supported", "unsupported"}
27
+ USAGE_ACCOUNTING = {"host-attested", "required", "unavailable"}
28
+ DATA_POLICIES = {"cloud-allowed", "local-only", "restricted"}
29
+ CALIBRATION_STATUSES = {"PASSED", "PENDING", "FAILED", "UNKNOWN"}
30
+ ESCALATION_LADDER_STEPS = {
31
+ "no-model",
32
+ "local-small-packet",
33
+ "standard-implementation",
34
+ "stronger-review",
35
+ "optional-cross-check",
36
+ }
37
+ FAILURE_CLASSES = {
38
+ "edge-case",
39
+ "api-contract",
40
+ "serialization",
41
+ "race",
42
+ "permission",
43
+ "migration",
44
+ "performance",
45
+ "flaky-test",
46
+ "security-bug",
47
+ "unknown",
48
+ }
49
+ HOST_MODEL_PROFILE_SCHEMAS = {
50
+ "agent-lifecycle-host-model-profile.v1",
51
+ "agent-host-model-selection-profile.v1",
52
+ }
53
+ BUDGET_MODES = {"metered", "subscription", "local"}
54
+
55
+
56
+ def load_model_routing_profile(path: Path) -> dict[str, Any]:
57
+ return read_json_object(path, label="model routing profile")
58
+
59
+
60
+ def load_host_model_profile(path: Path) -> dict[str, Any]:
61
+ return read_json_object(path, label="host model profile")
62
+
63
+
64
+ def validate_model_routing_profile(profile: dict[str, Any]) -> dict[str, Any]:
65
+ if profile.get("schemaVersion") != "agent-lifecycle-model-routing-profile.v1":
66
+ raise LifecycleError("invalid-model-routing-profile", "unsupported model routing profile schema")
67
+ profile_id = _required_str(profile, "profileId", "model routing profile")
68
+ classes = _required_string_list(profile, "classes", "model routing profile")
69
+ unknown = sorted(set(classes) - ALLOWED_MODEL_CLASSES)
70
+ if unknown:
71
+ raise LifecycleError("invalid-model-routing-profile", "unknown model classes", {"classes": unknown})
72
+ if "no-model" not in classes:
73
+ raise LifecycleError("invalid-model-routing-profile", "no-model class is required")
74
+ phase_rules = _required_dict(profile, "phaseRules", "model routing profile")
75
+ invalid_phase_rules = sorted(
76
+ phase
77
+ for phase, model_class in phase_rules.items()
78
+ if not isinstance(phase, str) or not phase or model_class not in classes
79
+ )
80
+ if invalid_phase_rules:
81
+ raise LifecycleError(
82
+ "invalid-model-routing-profile",
83
+ "phaseRules must map phases to declared model classes",
84
+ {"phases": invalid_phase_rules},
85
+ )
86
+ token_budgets = profile.get("tokenBudgets", {})
87
+ if token_budgets is not None:
88
+ _validate_token_budgets(token_budgets)
89
+ critical_phases = profile.get("criticalReviewPhases", [])
90
+ if critical_phases is not None:
91
+ _required_string_list(profile, "criticalReviewPhases", "model routing profile")
92
+ policy_presets = profile.get("policyPresets", {})
93
+ if policy_presets is not None:
94
+ _validate_policy_presets(policy_presets)
95
+ progressive = profile.get("progressiveEscalation", {})
96
+ if progressive is not None:
97
+ _validate_progressive_escalation(progressive)
98
+ return {
99
+ "schemaVersion": "agent-lifecycle-model-routing-profile-validation.v1",
100
+ "profileId": profile_id,
101
+ "classes": classes,
102
+ "phaseRuleCount": len(phase_rules),
103
+ "criticalReviewPhases": sorted(critical_phases),
104
+ "policyPresets": sorted(policy_presets),
105
+ "progressiveEscalation": bool(progressive.get("enabled")) if isinstance(progressive, dict) else False,
106
+ "profileDigest": canonical_digest(profile),
107
+ }
108
+
109
+
110
+ def validate_host_model_profile(profile: dict[str, Any]) -> dict[str, Any]:
111
+ schema_version = profile.get("schemaVersion")
112
+ if schema_version not in HOST_MODEL_PROFILE_SCHEMAS:
113
+ raise LifecycleError("invalid-host-model-profile", "unsupported host model profile schema")
114
+ profile_id = _required_str(profile, "profileId", "host model profile")
115
+ host = _required_str(profile, "host", "host model profile")
116
+ bindings = _required_dict(profile, "bindings", "host model profile")
117
+ if not bindings:
118
+ raise LifecycleError("invalid-host-model-profile", "bindings are required")
119
+ budget_mode_default: str | None = None
120
+ fallback_policies: dict[str, Any] | None = None
121
+ redaction_policy: dict[str, Any] | None = None
122
+ if schema_version == "agent-host-model-selection-profile.v1":
123
+ budget_mode_default = _required_str(profile, "budgetModeDefault", "host model profile")
124
+ if budget_mode_default not in BUDGET_MODES:
125
+ raise LifecycleError(
126
+ "invalid-host-model-profile",
127
+ "budgetModeDefault is unsupported",
128
+ {"budgetModeDefault": budget_mode_default},
129
+ )
130
+ fallback_policies = _required_dict(profile, "fallbackPolicies", "host model profile")
131
+ redaction_policy = _required_dict(profile, "redactionPolicy", "host model profile")
132
+ _validate_fallback_policies(fallback_policies)
133
+ _validate_redaction_policy(redaction_policy)
134
+ checked: dict[str, Any] = {}
135
+ for model_class, binding in bindings.items():
136
+ if model_class not in ALLOWED_MODEL_CLASSES:
137
+ raise LifecycleError(
138
+ "invalid-host-model-profile",
139
+ "unknown model class binding",
140
+ {"modelClass": model_class},
141
+ )
142
+ if model_class == "no-model":
143
+ raise LifecycleError("invalid-host-model-profile", "no-model must not have a host binding")
144
+ if not isinstance(binding, dict):
145
+ raise LifecycleError("invalid-host-model-profile", "binding must be an object", {"modelClass": model_class})
146
+ checked[model_class] = _validate_binding(model_class, binding)
147
+ return {
148
+ "schemaVersion": "agent-lifecycle-host-model-profile-validation.v1",
149
+ "host": host,
150
+ "profileId": profile_id,
151
+ "profileSchemaVersion": schema_version,
152
+ "budgetModeDefault": budget_mode_default,
153
+ "classes": sorted(checked),
154
+ "bindings": checked,
155
+ "fallbackPolicies": sorted(fallback_policies) if fallback_policies is not None else [],
156
+ "redactionPolicyDigest": canonical_digest(redaction_policy) if redaction_policy is not None else None,
157
+ "profileDigest": canonical_digest(profile),
158
+ }
159
+
160
+
161
+ def _validate_binding(model_class: str, binding: dict[str, Any]) -> dict[str, Any]:
162
+ provider_model = _required_str(binding, "providerModel", f"{model_class} binding")
163
+ context_window = _required_str(binding, "contextWindow", f"{model_class} binding")
164
+ if context_window not in WINDOWS:
165
+ raise LifecycleError(
166
+ "invalid-host-model-profile",
167
+ "binding contextWindow is unsupported",
168
+ {"modelClass": model_class, "contextWindow": context_window},
169
+ )
170
+ capabilities = _required_string_list(binding, "capabilities", f"{model_class} binding")
171
+ json_reliability = _enum(binding, "jsonReliability", JSON_RELIABILITY, default="best-effort")
172
+ tool_use = _enum(binding, "toolUse", TOOL_USE, default="unsupported")
173
+ usage_accounting = _enum(binding, "usageAccounting", USAGE_ACCOUNTING, default="unavailable")
174
+ data_policy = _enum(binding, "dataPolicy", DATA_POLICIES, default="restricted")
175
+ calibration_status = _enum(binding, "calibrationStatus", CALIBRATION_STATUSES, default="UNKNOWN")
176
+ allowed_tiers = _optional_limited_string_list(binding, "allowedForTiers", SDD_TIERS)
177
+ allowed_phases = _optional_string_list(binding, "allowedForPhases")
178
+ disallowed_risks = _optional_string_list(binding, "disallowedForRisks")
179
+ review_strategy = binding.get("reviewStrategy")
180
+ if review_strategy is not None and not isinstance(review_strategy, str):
181
+ raise LifecycleError("invalid-host-model-profile", "reviewStrategy must be a string", {"modelClass": model_class})
182
+ return {
183
+ "contextWindow": context_window,
184
+ "capabilities": capabilities,
185
+ "jsonReliability": json_reliability,
186
+ "toolUse": tool_use,
187
+ "allowedForTiers": allowed_tiers,
188
+ "allowedForPhases": allowed_phases,
189
+ "disallowedForRisks": disallowed_risks,
190
+ "usageAccounting": usage_accounting,
191
+ "dataPolicy": data_policy,
192
+ "calibrationStatus": calibration_status,
193
+ "reviewStrategy": review_strategy,
194
+ "providerModelHash": canonical_digest({"providerModel": provider_model}),
195
+ "bindingDigest": canonical_digest(binding),
196
+ }
197
+
198
+
199
+ def _validate_fallback_policies(value: dict[str, Any]) -> None:
200
+ for model_class, fallbacks in value.items():
201
+ if model_class not in ALLOWED_MODEL_CLASSES or model_class == "no-model":
202
+ raise LifecycleError(
203
+ "invalid-host-model-profile",
204
+ "fallbackPolicies must use routable model classes",
205
+ {"modelClass": model_class},
206
+ )
207
+ if not isinstance(fallbacks, list) or any(not isinstance(item, str) or not item for item in fallbacks):
208
+ raise LifecycleError(
209
+ "invalid-host-model-profile",
210
+ "fallbackPolicies values must be lists of model classes",
211
+ {"modelClass": model_class},
212
+ )
213
+ invalid = sorted(set(fallbacks) - (ALLOWED_MODEL_CLASSES - {"no-model"}))
214
+ if invalid:
215
+ raise LifecycleError(
216
+ "invalid-host-model-profile",
217
+ "fallbackPolicies contain unsupported model classes",
218
+ {"modelClass": model_class, "fallbacks": invalid},
219
+ )
220
+
221
+
222
+ def _validate_redaction_policy(value: dict[str, Any]) -> None:
223
+ provider_model_policy = value.get("providerModel")
224
+ if provider_model_policy != "hash":
225
+ raise LifecycleError(
226
+ "invalid-host-model-profile",
227
+ "redactionPolicy.providerModel must be hash",
228
+ {"providerModel": provider_model_policy},
229
+ )
230
+
231
+
232
+ def _validate_token_budgets(value: Any) -> None:
233
+ if not isinstance(value, dict):
234
+ raise LifecycleError("invalid-model-routing-profile", "tokenBudgets must be an object")
235
+ for tier, budgets in value.items():
236
+ if tier not in SDD_TIERS or not isinstance(budgets, dict):
237
+ raise LifecycleError("invalid-model-routing-profile", "tokenBudgets keys must be SDD tiers")
238
+ for budget_class, amount in budgets.items():
239
+ if not isinstance(budget_class, str) or not isinstance(amount, int) or isinstance(amount, bool) or amount < 0:
240
+ raise LifecycleError("invalid-model-routing-profile", "token budget values must be non-negative integers")
241
+
242
+
243
+ def _validate_policy_presets(value: Any) -> None:
244
+ if not isinstance(value, dict):
245
+ raise LifecycleError("invalid-model-routing-profile", "policyPresets must be an object")
246
+ unknown = sorted(set(value) - ROUTING_POLICIES)
247
+ if unknown:
248
+ raise LifecycleError("invalid-model-routing-profile", "unknown policy presets", {"policies": unknown})
249
+ for policy, payload in value.items():
250
+ if not isinstance(payload, dict):
251
+ raise LifecycleError("invalid-model-routing-profile", "policy preset must be an object", {"policy": policy})
252
+
253
+
254
+ def _validate_progressive_escalation(value: Any) -> None:
255
+ if not isinstance(value, dict):
256
+ raise LifecycleError("invalid-model-routing-profile", "progressiveEscalation must be an object")
257
+ enabled = value.get("enabled", False)
258
+ if not isinstance(enabled, bool):
259
+ raise LifecycleError("invalid-model-routing-profile", "progressiveEscalation.enabled must be boolean")
260
+ ladder = value.get("ladder", [])
261
+ if not isinstance(ladder, list) or ladder != [
262
+ "no-model",
263
+ "local-small-packet",
264
+ "standard-implementation",
265
+ "stronger-review",
266
+ "optional-cross-check",
267
+ ]:
268
+ raise LifecycleError("invalid-model-routing-profile", "progressiveEscalation.ladder must use the canonical order")
269
+ classes = value.get("failureClasses", [])
270
+ if not isinstance(classes, list) or sorted(classes) != sorted(FAILURE_CLASSES):
271
+ raise LifecycleError("invalid-model-routing-profile", "progressiveEscalation.failureClasses must list neutral failure classes")
272
+ triggers = value.get("evidenceTriggers", [])
273
+ if not isinstance(triggers, list) or any(not isinstance(item, str) or not item for item in triggers):
274
+ raise LifecycleError("invalid-model-routing-profile", "progressiveEscalation.evidenceTriggers must be strings")
275
+
276
+
277
+ def _required_str(payload: dict[str, Any], key: str, label: str) -> str:
278
+ value = payload.get(key)
279
+ if not isinstance(value, str) or not value:
280
+ raise LifecycleError("invalid-model-routing-profile", f"{label}: {key} is required")
281
+ return value
282
+
283
+
284
+ def _required_dict(payload: dict[str, Any], key: str, label: str) -> dict[str, Any]:
285
+ value = payload.get(key)
286
+ if not isinstance(value, dict):
287
+ raise LifecycleError("invalid-model-routing-profile", f"{label}: {key} object is required")
288
+ return value
289
+
290
+
291
+ def _required_string_list(payload: dict[str, Any], key: str, label: str) -> list[str]:
292
+ if key not in payload:
293
+ raise LifecycleError("invalid-model-routing-profile", f"{label}: {key} is required")
294
+ return _string_list(payload[key], key=key)
295
+
296
+
297
+ def _optional_string_list(payload: dict[str, Any], key: str) -> list[str]:
298
+ if key not in payload:
299
+ return []
300
+ return _string_list(payload[key], key=key)
301
+
302
+
303
+ def _optional_limited_string_list(payload: dict[str, Any], key: str, allowed: set[str]) -> list[str]:
304
+ values = _optional_string_list(payload, key)
305
+ invalid = sorted(set(values) - allowed)
306
+ if invalid:
307
+ raise LifecycleError("invalid-host-model-profile", f"{key} contains unsupported values", {"values": invalid})
308
+ return values
309
+
310
+
311
+ def _string_list(value: Any, *, key: str) -> list[str]:
312
+ if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value):
313
+ raise LifecycleError("invalid-model-routing-profile", f"{key} must be a list of non-empty strings")
314
+ if len(value) != len(set(value)):
315
+ raise LifecycleError("invalid-model-routing-profile", f"{key} contains duplicates")
316
+ return list(value)
317
+
318
+
319
+ def _enum(payload: dict[str, Any], key: str, allowed: set[str], *, default: str) -> str:
320
+ value = payload.get(key, default)
321
+ if value not in allowed:
322
+ raise LifecycleError("invalid-host-model-profile", f"{key} is unsupported", {"value": value})
323
+ return value
@@ -0,0 +1,140 @@
1
+ """Usage receipt validation for model-backed route decisions."""
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.model_routing.profiles import ALLOWED_MODEL_CLASSES, SDD_TIERS
9
+
10
+ USAGE_METRICS = {
11
+ "inputTokens",
12
+ "outputTokens",
13
+ "billableTokens",
14
+ "cumulativeContextBytes",
15
+ "toolCalls",
16
+ "wallSeconds",
17
+ }
18
+
19
+
20
+ def validate_usage_receipt(
21
+ receipt: dict[str, Any],
22
+ *,
23
+ budget_targets: dict[str, Any] | None = None,
24
+ route_decision: dict[str, Any] | None = None,
25
+ ) -> dict[str, Any]:
26
+ _validate_shape(receipt)
27
+ checks = [_attestation_check(receipt)]
28
+ if route_decision is not None:
29
+ checks.extend(_route_binding_checks(receipt, route_decision))
30
+ checks.extend(_budget_checks(receipt, budget_targets=budget_targets, route_decision=route_decision))
31
+ status = "PASS" if all(item["status"] == "PASS" for item in checks) else "FAIL"
32
+ payload = {
33
+ "schemaVersion": "agent-lifecycle-model-usage-validation.v1",
34
+ "status": status,
35
+ "operationId": receipt["operationId"],
36
+ "host": receipt["host"],
37
+ "modelClass": receipt["modelClass"],
38
+ "receiptDigest": canonical_digest(receipt),
39
+ "checks": checks,
40
+ }
41
+ if route_decision is not None:
42
+ payload["routeDecisionDigest"] = canonical_digest(route_decision)
43
+ return payload
44
+
45
+
46
+ def _validate_shape(receipt: dict[str, Any]) -> None:
47
+ if receipt.get("schemaVersion") != "agent-lifecycle-model-usage-receipt.v1":
48
+ raise LifecycleError("invalid-model-usage-receipt", "unsupported model usage receipt schema")
49
+ for key in ("operationId", "host", "modelClass", "providerModelHash"):
50
+ if not isinstance(receipt.get(key), str) or not receipt.get(key):
51
+ raise LifecycleError("invalid-model-usage-receipt", f"{key} is required")
52
+ if receipt["modelClass"] not in ALLOWED_MODEL_CLASSES - {"no-model"}:
53
+ raise LifecycleError("invalid-model-usage-receipt", "modelClass is unsupported")
54
+ usage = receipt.get("usage")
55
+ if not isinstance(usage, dict):
56
+ raise LifecycleError("invalid-model-usage-receipt", "usage object is required")
57
+ missing = sorted(metric for metric in USAGE_METRICS if not isinstance(usage.get(metric), int) or isinstance(usage.get(metric), bool))
58
+ if missing:
59
+ raise LifecycleError("invalid-model-usage-receipt", "usage metrics must be integers", {"missing": missing})
60
+ negative = sorted(metric for metric in USAGE_METRICS if usage[metric] < 0)
61
+ if negative:
62
+ raise LifecycleError("invalid-model-usage-receipt", "usage metrics must be non-negative", {"metrics": negative})
63
+ attestation = receipt.get("attestation")
64
+ if not isinstance(attestation, dict):
65
+ raise LifecycleError("invalid-model-usage-receipt", "attestation object is required")
66
+
67
+
68
+ def _attestation_check(receipt: dict[str, Any]) -> dict[str, Any]:
69
+ attestation = receipt["attestation"]
70
+ status = "PASS" if attestation.get("source") == "host" and attestation.get("status") == "ATTESTED" else "FAIL"
71
+ return {
72
+ "id": "host-usage-attestation",
73
+ "status": status,
74
+ "source": attestation.get("source"),
75
+ "attestationStatus": attestation.get("status"),
76
+ }
77
+
78
+
79
+ def _route_binding_checks(receipt: dict[str, Any], decision: dict[str, Any]) -> list[dict[str, Any]]:
80
+ expected_digest = decision.get("decisionDigest", canonical_digest(decision))
81
+ receipt_digest = receipt.get("routeDecisionDigest")
82
+ return [
83
+ {
84
+ "id": "operation-id-binding",
85
+ "status": "PASS" if receipt.get("operationId") == decision.get("operationId") else "FAIL",
86
+ "expected": decision.get("operationId"),
87
+ "actual": receipt.get("operationId"),
88
+ },
89
+ {
90
+ "id": "model-class-binding",
91
+ "status": "PASS" if receipt.get("modelClass") == decision.get("modelClass") else "FAIL",
92
+ "expected": decision.get("modelClass"),
93
+ "actual": receipt.get("modelClass"),
94
+ },
95
+ {
96
+ "id": "route-decision-digest-binding",
97
+ "status": "PASS" if receipt_digest in (None, expected_digest) else "FAIL",
98
+ "expected": expected_digest,
99
+ "actual": receipt_digest,
100
+ },
101
+ ]
102
+
103
+
104
+ def _budget_checks(
105
+ receipt: dict[str, Any],
106
+ *,
107
+ budget_targets: dict[str, Any] | None,
108
+ route_decision: dict[str, Any] | None,
109
+ ) -> list[dict[str, Any]]:
110
+ checks: list[dict[str, Any]] = []
111
+ usage = receipt["usage"]
112
+ if route_decision is not None and isinstance(route_decision.get("maxBillableTokens"), int):
113
+ limit = route_decision["maxBillableTokens"]
114
+ checks.append({
115
+ "id": "route-max-billable-tokens",
116
+ "status": "PASS" if usage["billableTokens"] <= limit else "FAIL",
117
+ "value": usage["billableTokens"],
118
+ "limit": limit,
119
+ })
120
+ if budget_targets is None:
121
+ return checks
122
+ tier = receipt.get("sddTier") or (route_decision or {}).get("sddTier")
123
+ if tier not in SDD_TIERS:
124
+ checks.append({"id": "budget-tier-binding", "status": "FAIL", "tier": tier})
125
+ return checks
126
+ hard = budget_targets.get("hardCeilings", {}).get(tier)
127
+ if not isinstance(hard, dict):
128
+ checks.append({"id": "budget-target-present", "status": "FAIL", "tier": tier})
129
+ return checks
130
+ for metric in ("billableTokens", "cumulativeContextBytes", "toolCalls", "wallSeconds"):
131
+ limit = hard.get(metric)
132
+ if isinstance(limit, int):
133
+ checks.append({
134
+ "id": f"hard-ceiling-{metric}",
135
+ "status": "PASS" if usage[metric] <= limit else "FAIL",
136
+ "value": usage[metric],
137
+ "limit": limit,
138
+ "tier": tier,
139
+ })
140
+ return checks