capsule-engine 0.1.0__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 (288) hide show
  1. capsule_engine/__init__.py +17 -0
  2. capsule_engine/bundle_viewer/__init__.py +21 -0
  3. capsule_engine/bundle_viewer/static/offline_shell.html +1509 -0
  4. capsule_engine/bundle_viewer/viewer.py +57 -0
  5. capsule_engine/cli/__init__.py +5 -0
  6. capsule_engine/cli/bundle_cmd.py +269 -0
  7. capsule_engine/cli/console_cmd.py +70 -0
  8. capsule_engine/cli/constraints_cmd.py +74 -0
  9. capsule_engine/cli/format.py +173 -0
  10. capsule_engine/cli/guard_cmds.py +361 -0
  11. capsule_engine/cli/init_cmds.py +169 -0
  12. capsule_engine/cli/main.py +120 -0
  13. capsule_engine/cli/packs_cmds.py +96 -0
  14. capsule_engine/cli/telemetry_cmd.py +80 -0
  15. capsule_engine/cli/tenant_cmds.py +199 -0
  16. capsule_engine/cli/verify_cmd.py +110 -0
  17. capsule_engine/compiler/__init__.py +13 -0
  18. capsule_engine/compiler/effect_model.py +79 -0
  19. capsule_engine/compiler/vocabulary.py +253 -0
  20. capsule_engine/console/__init__.py +10 -0
  21. capsule_engine/console/api.py +251 -0
  22. capsule_engine/console/components.css +406 -0
  23. capsule_engine/console/console.css +130 -0
  24. capsule_engine/console/console.html +51 -0
  25. capsule_engine/console/console.js +330 -0
  26. capsule_engine/console/gallery.html +274 -0
  27. capsule_engine/console/server.py +137 -0
  28. capsule_engine/console/tokens.css +68 -0
  29. capsule_engine/conversation/__init__.py +88 -0
  30. capsule_engine/conversation/capsules.py +268 -0
  31. capsule_engine/conversation/exchange.py +279 -0
  32. capsule_engine/conversation/merkle.py +80 -0
  33. capsule_engine/conversation/session.py +101 -0
  34. capsule_engine/envcompat.py +15 -0
  35. capsule_engine/events/__init__.py +14 -0
  36. capsule_engine/events/capsule.py +133 -0
  37. capsule_engine/examples/__init__.py +9 -0
  38. capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
  39. capsule_engine/examples/data/tau2_airline/PROVENANCE.json +70 -0
  40. capsule_engine/examples/data/tau2_airline/hand_labels.json +893 -0
  41. capsule_engine/examples/data/tau2_airline/pilot1_live_gemini-2-5-flash_airline_shift1.jsonl +190 -0
  42. capsule_engine/examples/data/tau2_airline/tau2_committed_claude-3-7-sonnet_airline_trial0.jsonl +556 -0
  43. capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1-mini_airline_trial0.jsonl +480 -0
  44. capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1_airline_trial0.jsonl +496 -0
  45. capsule_engine/examples/data/tau2_airline/tau2_committed_o4-mini_airline_trial0.jsonl +392 -0
  46. capsule_engine/examples/data/tau2_airline/tau2_conversations_claude-3-7-sonnet_airline_4trials.jsonl +200 -0
  47. capsule_engine/examples/tau2_airline_reference.py +460 -0
  48. capsule_engine/examples/tau2_conversation_exchange.py +92 -0
  49. capsule_engine/folds/__init__.py +104 -0
  50. capsule_engine/folds/account_core.py +89 -0
  51. capsule_engine/folds/approval_latency.py +86 -0
  52. capsule_engine/folds/catalog.py +79 -0
  53. capsule_engine/folds/catalog_defs/actions.count_by_developer.yaml +10 -0
  54. capsule_engine/folds/catalog_defs/actions.executed_count.yaml +16 -0
  55. capsule_engine/folds/catalog_defs/actions.last_decision.yaml +12 -0
  56. capsule_engine/folds/catalog_defs/counterparty.seen_before.v2.yaml +34 -0
  57. capsule_engine/folds/catalog_defs/counterparty.seen_before.v3.yaml +38 -0
  58. capsule_engine/folds/catalog_defs/counterparty.seen_before.yaml +28 -0
  59. capsule_engine/folds/catalog_defs/disclosure.class_seen.yaml +29 -0
  60. capsule_engine/folds/catalog_defs/disclosure.statement_seen.yaml +32 -0
  61. capsule_engine/folds/catalog_defs/hold.active_exposure.yaml +44 -0
  62. capsule_engine/folds/catalog_defs/spend.weekly.v2.yaml +38 -0
  63. capsule_engine/folds/catalog_defs/spend.weekly.v3.1.yaml +71 -0
  64. capsule_engine/folds/catalog_defs/spend.weekly.v3.yaml +76 -0
  65. capsule_engine/folds/catalog_defs/spend.weekly.yaml +33 -0
  66. capsule_engine/folds/counterparty_signals.py +179 -0
  67. capsule_engine/folds/definition.py +421 -0
  68. capsule_engine/folds/duration.py +16 -0
  69. capsule_engine/folds/engine.py +436 -0
  70. capsule_engine/folds/errors.py +44 -0
  71. capsule_engine/folds/loader.py +24 -0
  72. capsule_engine/folds/ordering.py +143 -0
  73. capsule_engine/folds/paths.py +25 -0
  74. capsule_engine/folds/record_type_coverage.py +58 -0
  75. capsule_engine/folds/reducers.py +75 -0
  76. capsule_engine/folds/retention_continuity.py +117 -0
  77. capsule_engine/folds/set_membership.py +59 -0
  78. capsule_engine/folds/taxonomy.py +231 -0
  79. capsule_engine/guards/__init__.py +59 -0
  80. capsule_engine/guards/action.py +446 -0
  81. capsule_engine/guards/action_taxonomy.json +27 -0
  82. capsule_engine/guards/action_taxonomy_v2.json +27 -0
  83. capsule_engine/guards/action_taxonomy_v3.json +27 -0
  84. capsule_engine/guards/action_taxonomy_v4.json +27 -0
  85. capsule_engine/guards/action_taxonomy_v5.json +27 -0
  86. capsule_engine/guards/capsule.py +377 -0
  87. capsule_engine/guards/checks/__init__.py +268 -0
  88. capsule_engine/guards/checks/action_class_gate.py +199 -0
  89. capsule_engine/guards/checks/base.py +33 -0
  90. capsule_engine/guards/checks/caps.py +517 -0
  91. capsule_engine/guards/checks/channel_change.py +64 -0
  92. capsule_engine/guards/checks/commitment.py +41 -0
  93. capsule_engine/guards/checks/counterparty_identity_change.py +95 -0
  94. capsule_engine/guards/checks/counterparty_list.py +176 -0
  95. capsule_engine/guards/checks/counterparty_seen_before.py +160 -0
  96. capsule_engine/guards/checks/credential_pattern.py +73 -0
  97. capsule_engine/guards/checks/dedupe.py +259 -0
  98. capsule_engine/guards/checks/destination_rail.py +120 -0
  99. capsule_engine/guards/checks/field_change_count.py +100 -0
  100. capsule_engine/guards/checks/offer_expiry.py +134 -0
  101. capsule_engine/guards/checks/plan_containment.py +180 -0
  102. capsule_engine/guards/checks/price_floor.py +211 -0
  103. capsule_engine/guards/checks/promise_never.py +236 -0
  104. capsule_engine/guards/checks/promise_requires_approval.py +214 -0
  105. capsule_engine/guards/checks/recipient_role.py +117 -0
  106. capsule_engine/guards/checks/recurring_charge.py +49 -0
  107. capsule_engine/guards/checks/refundability.py +46 -0
  108. capsule_engine/guards/checks/release_on_acceptance.py +286 -0
  109. capsule_engine/guards/checks/required_disclosure.py +233 -0
  110. capsule_engine/guards/checks/single_commitment.py +221 -0
  111. capsule_engine/guards/checks/task_authority.py +182 -0
  112. capsule_engine/guards/checks/upfront_amount.py +71 -0
  113. capsule_engine/guards/checks/verify_before_dispatch.py +75 -0
  114. capsule_engine/guards/classes.py +393 -0
  115. capsule_engine/guards/engine.py +926 -0
  116. capsule_engine/guards/history_state.py +127 -0
  117. capsule_engine/guards/plan.py +237 -0
  118. capsule_engine/guards/revocation.py +120 -0
  119. capsule_engine/guards/signing.py +60 -0
  120. capsule_engine/guards/statements.py +62 -0
  121. capsule_engine/guards/tool_call.py +83 -0
  122. capsule_engine/guards/wickets/__init__.py +23 -0
  123. capsule_engine/guards/wickets/catalog.py +75 -0
  124. capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.1.yaml +38 -0
  125. capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.2.yaml +52 -0
  126. capsule_engine/guards/wickets/catalog_defs/action_class_gate.yaml +34 -0
  127. capsule_engine/guards/wickets/catalog_defs/caps.v2.yaml +30 -0
  128. capsule_engine/guards/wickets/catalog_defs/caps.v3.yaml +49 -0
  129. capsule_engine/guards/wickets/catalog_defs/caps.v4.yaml +45 -0
  130. capsule_engine/guards/wickets/catalog_defs/caps.v5.1.yaml +53 -0
  131. capsule_engine/guards/wickets/catalog_defs/caps.v5.yaml +43 -0
  132. capsule_engine/guards/wickets/catalog_defs/caps.yaml +17 -0
  133. capsule_engine/guards/wickets/catalog_defs/caps_holds.yaml +18 -0
  134. capsule_engine/guards/wickets/catalog_defs/channel_change.v1.1.yaml +18 -0
  135. capsule_engine/guards/wickets/catalog_defs/channel_change.yaml +10 -0
  136. capsule_engine/guards/wickets/catalog_defs/counterparty_identity_change.yaml +9 -0
  137. capsule_engine/guards/wickets/catalog_defs/counterparty_list.yaml +20 -0
  138. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v2.yaml +15 -0
  139. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v3.yaml +23 -0
  140. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v4.yaml +26 -0
  141. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.yaml +14 -0
  142. capsule_engine/guards/wickets/catalog_defs/credential_pattern.yaml +14 -0
  143. capsule_engine/guards/wickets/catalog_defs/dedupe.yaml +19 -0
  144. capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.v1.1.yaml +32 -0
  145. capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.yaml +10 -0
  146. capsule_engine/guards/wickets/catalog_defs/destination_rail.yaml +9 -0
  147. capsule_engine/guards/wickets/catalog_defs/hold_reconcile.yaml +14 -0
  148. capsule_engine/guards/wickets/catalog_defs/material_fields_changed.v1.1.yaml +22 -0
  149. capsule_engine/guards/wickets/catalog_defs/material_fields_changed.yaml +14 -0
  150. capsule_engine/guards/wickets/catalog_defs/offer_expiry.v1.1.yaml +31 -0
  151. capsule_engine/guards/wickets/catalog_defs/offer_expiry.yaml +24 -0
  152. capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.v1.1.yaml +22 -0
  153. capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.yaml +10 -0
  154. capsule_engine/guards/wickets/catalog_defs/price_floor.yaml +32 -0
  155. capsule_engine/guards/wickets/catalog_defs/promise_never.v1.1.yaml +64 -0
  156. capsule_engine/guards/wickets/catalog_defs/promise_never.yaml +16 -0
  157. capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.v1.1.yaml +26 -0
  158. capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.yaml +21 -0
  159. capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.1.yaml +23 -0
  160. capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.2.yaml +24 -0
  161. capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.yaml +10 -0
  162. capsule_engine/guards/wickets/catalog_defs/recipient_role.v1.1.yaml +26 -0
  163. capsule_engine/guards/wickets/catalog_defs/recipient_role.yaml +11 -0
  164. capsule_engine/guards/wickets/catalog_defs/recipient_seen_before.yaml +16 -0
  165. capsule_engine/guards/wickets/catalog_defs/recurring_charge.yaml +9 -0
  166. capsule_engine/guards/wickets/catalog_defs/refundability.v1.1.yaml +15 -0
  167. capsule_engine/guards/wickets/catalog_defs/refundability.yaml +7 -0
  168. capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.1.yaml +44 -0
  169. capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.2.yaml +63 -0
  170. capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.yaml +36 -0
  171. capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.1.yaml +36 -0
  172. capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.2.yaml +42 -0
  173. capsule_engine/guards/wickets/catalog_defs/required_disclosure.yaml +25 -0
  174. capsule_engine/guards/wickets/catalog_defs/single_commitment.seller.yaml +14 -0
  175. capsule_engine/guards/wickets/catalog_defs/task_authority.v1.1.yaml +20 -0
  176. capsule_engine/guards/wickets/catalog_defs/task_authority.yaml +8 -0
  177. capsule_engine/guards/wickets/catalog_defs/upfront_amount.v1.1.yaml +19 -0
  178. capsule_engine/guards/wickets/catalog_defs/upfront_amount.yaml +11 -0
  179. capsule_engine/guards/wickets/catalog_defs/verify_before_dispatch.yaml +9 -0
  180. capsule_engine/guards/wickets/definition.py +164 -0
  181. capsule_engine/guards/wickets/errors.py +18 -0
  182. capsule_engine/guards/wickets/loader.py +26 -0
  183. capsule_engine/guards/wickets/retired.py +62 -0
  184. capsule_engine/holds/__init__.py +35 -0
  185. capsule_engine/holds/capsules.py +266 -0
  186. capsule_engine/holds/engine.py +476 -0
  187. capsule_engine/holds/errors.py +29 -0
  188. capsule_engine/holds/policy.py +62 -0
  189. capsule_engine/holds/scope.py +47 -0
  190. capsule_engine/mcp/__init__.py +16 -0
  191. capsule_engine/mcp/config.py +117 -0
  192. capsule_engine/mcp/server.py +338 -0
  193. capsule_engine/mcp/tools.py +424 -0
  194. capsule_engine/packaging.py +60 -0
  195. capsule_engine/packs/__init__.py +45 -0
  196. capsule_engine/packs/backfill_coverage.py +238 -0
  197. capsule_engine/packs/catalog/airline-engagement/AI-BOOTSTRAP.md +53 -0
  198. capsule_engine/packs/catalog/airline-engagement/folds/session_close.yaml +8 -0
  199. capsule_engine/packs/catalog/airline-engagement/pack.yaml +146 -0
  200. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/good_execution.json +36 -0
  201. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/oversight_declared.json +36 -0
  202. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/tampered_execution.json +36 -0
  203. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/trusted_issuer.jwk.json +5 -0
  204. capsule_engine/packs/catalog/eu-ai-act/obligations-pack-note.md +68 -0
  205. capsule_engine/packs/catalog/eu-ai-act/obligations-register.yaml +761 -0
  206. capsule_engine/packs/catalog/eu-ai-act/pack.yaml +88 -0
  207. capsule_engine/packs/catalog/eu-ai-act/register.yaml +96 -0
  208. capsule_engine/packs/catalog/eu-ai-act-deterministic/fixtures/synthetic_internal_assist_sample.jsonl +6 -0
  209. capsule_engine/packs/catalog/eu-ai-act-deterministic/pack.yaml +580 -0
  210. capsule_engine/packs/catalog/everyday/AI-BOOTSTRAP.md +194 -0
  211. capsule_engine/packs/catalog/everyday/fixtures/mini_ledger.jsonl +45 -0
  212. capsule_engine/packs/catalog/everyday/pack.yaml +480 -0
  213. capsule_engine/packs/catalog/payments-safety/AI-BOOTSTRAP.md +83 -0
  214. capsule_engine/packs/catalog/payments-safety/fixtures/mini_ledger.jsonl +8 -0
  215. capsule_engine/packs/catalog/payments-safety/folds/spend_weekly.yaml +29 -0
  216. capsule_engine/packs/catalog/payments-safety/pack.yaml +112 -0
  217. capsule_engine/packs/catalog/seller/fixtures/mini_ledger.jsonl +83 -0
  218. capsule_engine/packs/catalog/seller/pack.yaml +503 -0
  219. capsule_engine/packs/catalog/standard-vendor/AI-BOOTSTRAP.md +70 -0
  220. capsule_engine/packs/catalog/standard-vendor/folds/session_close.yaml +8 -0
  221. capsule_engine/packs/catalog/standard-vendor/pack.yaml +466 -0
  222. capsule_engine/packs/contract_diff.py +809 -0
  223. capsule_engine/packs/contract_validate.py +185 -0
  224. capsule_engine/packs/corpus_verify.py +126 -0
  225. capsule_engine/packs/enforce.py +81 -0
  226. capsule_engine/packs/errors.py +159 -0
  227. capsule_engine/packs/eu_ai_act_trace_field_map.py +46 -0
  228. capsule_engine/packs/install.py +266 -0
  229. capsule_engine/packs/loader.py +1462 -0
  230. capsule_engine/packs/measurability_report.py +280 -0
  231. capsule_engine/packs/obligation_results.py +72 -0
  232. capsule_engine/packs/pins.py +92 -0
  233. capsule_engine/packs/schema.py +1053 -0
  234. capsule_engine/packs/thresholds.py +146 -0
  235. capsule_engine/packs/trace_attainment.py +188 -0
  236. capsule_engine/packs/trace_reader.py +104 -0
  237. capsule_engine/policy/__init__.py +37 -0
  238. capsule_engine/policy/activation.py +87 -0
  239. capsule_engine/policy/catalog_defs/default.yaml +23 -0
  240. capsule_engine/policy/catalog_defs/holds.yaml +27 -0
  241. capsule_engine/policy/errors.py +46 -0
  242. capsule_engine/policy/limits.py +287 -0
  243. capsule_engine/policy/loader.py +26 -0
  244. capsule_engine/policy/manifest.py +289 -0
  245. capsule_engine/policy/profile.py +281 -0
  246. capsule_engine/policy/resolve.py +224 -0
  247. capsule_engine/register/__init__.py +39 -0
  248. capsule_engine/register/compiler.py +219 -0
  249. capsule_engine/register/errors.py +40 -0
  250. capsule_engine/register/loader.py +247 -0
  251. capsule_engine/register/schema.py +129 -0
  252. capsule_engine/registry/__init__.py +19 -0
  253. capsule_engine/registry/conventions.json +162 -0
  254. capsule_engine/registry/conventions.py +188 -0
  255. capsule_engine/registry/cpb_registry.json +78 -0
  256. capsule_engine/report/__init__.py +62 -0
  257. capsule_engine/report/build.py +267 -0
  258. capsule_engine/report/coverage.py +777 -0
  259. capsule_engine/report/errors.py +35 -0
  260. capsule_engine/report/live_check.py +126 -0
  261. capsule_engine/report/live_history.py +420 -0
  262. capsule_engine/report/model.py +95 -0
  263. capsule_engine/report/render.py +467 -0
  264. capsule_engine/report/replay.py +1677 -0
  265. capsule_engine/report/result.py +504 -0
  266. capsule_engine/report/result_from_folds.py +222 -0
  267. capsule_engine/report/result_from_layers.py +670 -0
  268. capsule_engine/report/sale_bundle.py +864 -0
  269. capsule_engine/report/static/verify.js +451 -0
  270. capsule_engine/schemas/evidence-contract-v0.json +383 -0
  271. capsule_engine/schemas/vendor/epistemic-types.json +21 -0
  272. capsule_engine/schemas/vendor/evidence-result-v0.json +463 -0
  273. capsule_engine/telemetry/__init__.py +9 -0
  274. capsule_engine/telemetry/consent.py +39 -0
  275. capsule_engine/telemetry/events.py +93 -0
  276. capsule_engine/telemetry/fixtures/synthetic_events.jsonl +45 -0
  277. capsule_engine/telemetry/funnel.py +147 -0
  278. capsule_engine/telemetry/record.py +106 -0
  279. capsule_engine/telemetry/sink.py +61 -0
  280. capsule_engine/telemetry/state.py +60 -0
  281. capsule_engine/tenants.py +327 -0
  282. capsule_engine-0.1.0.dist-info/METADATA +47 -0
  283. capsule_engine-0.1.0.dist-info/RECORD +288 -0
  284. capsule_engine-0.1.0.dist-info/WHEEL +5 -0
  285. capsule_engine-0.1.0.dist-info/entry_points.txt +2 -0
  286. capsule_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
  287. capsule_engine-0.1.0.dist-info/licenses/capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
  288. capsule_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,287 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Which ``caps`` limit is in force for a decision, and where it came from.
3
+
4
+ A user's limit lives in a policy profile (``profile.py``) that an activation
5
+ record binds (``activation.py``): the record carries the profile's values and
6
+ digest and is appended, signed, to the ledger. ``ResolvedManifest`` lays the
7
+ profile over the wicket's defaults as soon as it is installed; this module
8
+ adds the one rule that depends on *when* a value was activated:
9
+
10
+ * a value at or below the limit in force when it was activated takes effect
11
+ at that activation's timestamp;
12
+ * a value above it takes effect ``COOLING_OFF`` (12 hours) after that
13
+ timestamp, and until then the earlier limit stays in force;
14
+ * each activation supersedes any raise an earlier one left pending.
15
+
16
+ "Earlier limit" starts at the wicket's default, so a first profile that sets
17
+ a limit above the pack's default waits too, and so does removing a profile
18
+ whose limit was below it. Lower is tighter and higher is looser, as
19
+ ``packs/contract_diff.py`` classifies a profile limit; that diff reports a
20
+ class set on only one side as ``changed`` because it does not read the pack
21
+ default, and here the default is known, so every change has a direction.
22
+
23
+ Which records count. The history is every activation in the ledger, in
24
+ append order, and it is read only when a profile is involved -- the install
25
+ pins one, or some activation carries one; otherwise every limit is the
26
+ wicket's default. When it is read:
27
+
28
+ * every record must verify under the engine's own signer
29
+ (``events.event_signature_valid``), so a record appended by anything that
30
+ does not hold the node's key is refused, not ignored;
31
+ * timestamps must not decrease in append order, so a record cannot be
32
+ backdated ahead of one already appended;
33
+ * the latest record must cite the installed manifest and the profile digest
34
+ that manifest pins.
35
+
36
+ A class a record's profile does not set reads the *installed* wicket's
37
+ default, including for records made under an earlier wicket. A pack upgrade
38
+ that changes a default is therefore not itself put through the cooling-off;
39
+ a value the operator set is, across any upgrade.
40
+
41
+ ``CapsLimits.in_force`` is asked at two times -- the action's own timestamp
42
+ and the engine's clock -- and returns the lower of the two limits, so a
43
+ caller cannot reach a raise early by stamping its action later, or escape a
44
+ lowering by stamping it earlier. The value comes with its provenance --
45
+ ``operator_profile`` with the digest of the profile that set it, or
46
+ ``definition_default`` -- plus any activated raise still waiting out the
47
+ cooling-off. The caps check seals that provenance in its evidence
48
+ (``guards/checks/caps.py``'s ``limit_sources``).
49
+ """
50
+ from __future__ import annotations
51
+
52
+ from collections.abc import Mapping, Sequence
53
+ from dataclasses import dataclass
54
+ from datetime import datetime, timedelta, timezone
55
+ from itertools import pairwise
56
+
57
+ from cll.ledger.api import LedgerAPI, ScanQuery
58
+
59
+ from ..events.capsule import event_signature_valid
60
+ from ..guards.checks.caps import (
61
+ LIMIT_SOURCE_DEFAULT,
62
+ LIMIT_SOURCE_PROFILE,
63
+ LimitSource,
64
+ PendingRaise,
65
+ cap_for,
66
+ )
67
+ from ..guards.classes import resolve
68
+ from ..guards.signing import Signer
69
+ from .activation import EVENT_MANIFEST_ACTIVATED
70
+ from .errors import (
71
+ ACTIVATION_OUT_OF_ORDER,
72
+ ACTIVATION_UNVERIFIED,
73
+ PROFILE_DIGEST_DRIFT,
74
+ PROFILE_UNBOUND,
75
+ PolicyManifestError,
76
+ )
77
+ from .profile import parse_profile
78
+ from .resolve import ResolvedManifest
79
+
80
+ __all__ = [
81
+ "COOLING_OFF",
82
+ "ActivationRecord",
83
+ "CapsLimits",
84
+ "LimitInForce",
85
+ "activation_records",
86
+ "caps_limits",
87
+ "read_caps_limits",
88
+ ]
89
+
90
+ COOLING_OFF = timedelta(hours=12)
91
+
92
+ # The caps config keys a profile may set (``profile.CONFIGURABLE["caps"]``).
93
+ _KEYS = ("caps_minor", "per_action_minor")
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ActivationRecord:
98
+ """One ``policy_manifest_activated`` capsule, as appended."""
99
+
100
+ capsule: Mapping
101
+
102
+ @property
103
+ def timestamp(self) -> str:
104
+ return self.capsule["timestamp"]
105
+
106
+ @property
107
+ def detail(self) -> Mapping:
108
+ return self.capsule["asg_payload"]["detail"]
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class LimitInForce:
113
+ value_minor: int
114
+ source: LimitSource
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class _Step:
119
+ # Both ``None`` for the wicket's default before any activation.
120
+ activated_at: datetime | None
121
+ effective_at: datetime | None
122
+ value_minor: int
123
+ # ``None``: the wicket's default.
124
+ profile_digest: str | None
125
+
126
+
127
+ def _parse_ts(ts: str) -> datetime:
128
+ """An ISO-8601 timestamp; one with no UTC offset is read as UTC, the
129
+ engine's convention for every timestamp it writes."""
130
+ dt = datetime.fromisoformat(ts[:-1] + "+00:00" if ts.endswith("Z") else ts)
131
+ return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
132
+
133
+
134
+ def _format_ts(dt: datetime) -> str:
135
+ return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
136
+
137
+
138
+ def _canonical(action_class: str) -> str:
139
+ ac = resolve(action_class)
140
+ return ac.name if ac is not None else action_class
141
+
142
+
143
+ def _source_name(step: _Step) -> str:
144
+ return LIMIT_SOURCE_DEFAULT if step.profile_digest is None else LIMIT_SOURCE_PROFILE
145
+
146
+
147
+ def _at(steps: tuple[_Step, ...], when: datetime) -> LimitInForce:
148
+ """The limit in force at ``when``, and the raise pending then, if any:
149
+ one activated by ``when`` that takes effect after it."""
150
+ current = steps[0]
151
+ pending: _Step | None = None
152
+ for step in steps:
153
+ if step.effective_at is None or step.effective_at <= when:
154
+ current = step
155
+ elif step.activated_at is not None and step.activated_at <= when:
156
+ pending = step
157
+ source = LimitSource(limit_source=_source_name(current))
158
+ if current.profile_digest is not None:
159
+ source["profile_digest"] = current.profile_digest
160
+ if pending is not None and pending.effective_at is not None and pending.value_minor > current.value_minor:
161
+ raise_ = PendingRaise(
162
+ value_minor=pending.value_minor,
163
+ effective_at=_format_ts(pending.effective_at),
164
+ limit_source=_source_name(pending),
165
+ )
166
+ if pending.profile_digest is not None:
167
+ raise_["profile_digest"] = pending.profile_digest
168
+ source["pending_raise"] = raise_
169
+ return LimitInForce(value_minor=current.value_minor, source=source)
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class CapsLimits:
174
+ """Per caps key (``caps_minor`` / ``per_action_minor``), per canonical
175
+ action class, the limit's steps in activation order. ``effective_at`` is
176
+ non-decreasing along each tuple."""
177
+
178
+ steps: Mapping[str, Mapping[str, tuple[_Step, ...]]]
179
+
180
+ def in_force(self, key: str, action_class: str | None, *, at: str, now: str) -> LimitInForce | None:
181
+ """The ``key`` limit for ``action_class``: the lower of the limits in
182
+ force at ``at`` (the action's timestamp) and ``now`` (the engine's
183
+ clock), the one at ``at`` on a tie. ``None`` when the installed wicket
184
+ sets no such limit for the class."""
185
+ by_class = self.steps.get(key, {})
186
+ if action_class is None or cap_for(dict.fromkeys(by_class, 0), action_class) is None:
187
+ return None
188
+ steps = by_class[_canonical(action_class)]
189
+ at_action = _at(steps, _parse_ts(at))
190
+ at_clock = _at(steps, _parse_ts(now))
191
+ return at_clock if at_clock.value_minor < at_action.value_minor else at_action
192
+
193
+
194
+ def activation_records(ledger: LedgerAPI) -> list[ActivationRecord]:
195
+ """Every ``policy_manifest_activated`` record in ``ledger``, in append order."""
196
+ return [
197
+ ActivationRecord(capsule=record.capsule)
198
+ for record in ledger.scan(ScanQuery(action_type="fyi"))
199
+ if (record.capsule.get("asg_payload") or {}).get("event") == EVENT_MANIFEST_ACTIVATED
200
+ ]
201
+
202
+
203
+ def _profile_values(detail: Mapping, pack_names: set[str]) -> tuple[str | None, dict[str, dict[str, int]]]:
204
+ """The profile digest an activation cites and the caps values it sets,
205
+ key -> class -> value. The values are re-digested: a record whose values
206
+ do not digest to the digest it cites is refused."""
207
+ profile = detail.get("profile")
208
+ if profile is None:
209
+ return None, {}
210
+ parsed = parse_profile(profile["values"])
211
+ if parsed.profile_digest() != profile["profile_digest"]:
212
+ raise PolicyManifestError(
213
+ PROFILE_DIGEST_DRIFT,
214
+ f"activation cites profile {profile['profile_digest']}, but its values digest to {parsed.profile_digest()}",
215
+ )
216
+ values: dict[str, dict[str, int]] = {}
217
+ for entry in parsed.packs:
218
+ if entry.pack in pack_names:
219
+ for key, by_class in entry.parameters.get("caps", {}).items():
220
+ values.setdefault(key, {}).update(by_class)
221
+ return profile["profile_digest"], values
222
+
223
+
224
+ def _check_history(resolved: ResolvedManifest, activations: Sequence[ActivationRecord], signer: Signer) -> None:
225
+ """Refuse a history this module cannot read limits from (module docstring)."""
226
+ for record in activations:
227
+ if not event_signature_valid(record.capsule, signer):
228
+ raise PolicyManifestError(
229
+ ACTIVATION_UNVERIFIED,
230
+ f"activation {record.capsule.get('capsule_id')} does not verify under key {signer.key_id!r}",
231
+ )
232
+ for earlier, later in pairwise(activations):
233
+ if _parse_ts(later.timestamp) < _parse_ts(earlier.timestamp):
234
+ raise PolicyManifestError(
235
+ ACTIVATION_OUT_OF_ORDER,
236
+ f"activation {later.capsule.get('capsule_id')} at {later.timestamp} is appended after one at "
237
+ f"{earlier.timestamp}",
238
+ )
239
+ latest = activations[-1].detail if activations else None
240
+ cited = (latest.get("profile") or {}).get("profile_digest") if latest is not None else None
241
+ if latest is None or latest.get("manifest_digest") != resolved.manifest_digest or cited != resolved.manifest.profile_digest:
242
+ raise PolicyManifestError(
243
+ PROFILE_UNBOUND,
244
+ f"installed manifest {resolved.manifest_digest} (profile {resolved.manifest.profile_digest}) is not the "
245
+ f"one the latest activation binds ({latest.get('manifest_digest') if latest else 'none recorded'}, "
246
+ f"profile {cited}); a profile's limits apply only once an activation binds them",
247
+ )
248
+
249
+
250
+ def caps_limits(resolved: ResolvedManifest, activations: Sequence[ActivationRecord], signer: Signer) -> CapsLimits:
251
+ """The caps limits of ``resolved`` over time, from the ledger's activation
252
+ records (``activation_records``), each verified under ``signer``. Raises
253
+ ``PolicyManifestError`` when a profile is involved and the history cannot
254
+ be read (module docstring)."""
255
+ defaults = resolved.wicket_config("caps")
256
+ pack_names = {p.pack_id.rsplit("/", 1)[0] for p in resolved.manifest.packs}
257
+ involved = resolved.profile is not None or any(r.detail.get("profile") is not None for r in activations)
258
+ history: list[tuple[datetime, str | None, dict[str, dict[str, int]]]] = []
259
+ if involved:
260
+ _check_history(resolved, activations, signer)
261
+ history = [(_parse_ts(r.timestamp), *_profile_values(r.detail, pack_names)) for r in activations]
262
+
263
+ steps: dict[str, dict[str, tuple[_Step, ...]]] = {}
264
+ for key in _KEYS:
265
+ for action_class, default in (defaults.get(key) or {}).items():
266
+ schedule = [_Step(activated_at=None, effective_at=None, value_minor=default, profile_digest=None)]
267
+ for activated_at, digest, values in history:
268
+ set_by_profile = action_class in values.get(key, {})
269
+ value = values[key][action_class] if set_by_profile else default
270
+ # Drop any raise not yet in force: this activation supersedes it.
271
+ schedule = [s for s in schedule if s.effective_at is None or s.effective_at <= activated_at]
272
+ effective_at = activated_at if value <= schedule[-1].value_minor else activated_at + COOLING_OFF
273
+ schedule.append(
274
+ _Step(
275
+ activated_at=activated_at,
276
+ effective_at=effective_at,
277
+ value_minor=value,
278
+ profile_digest=digest if set_by_profile else None,
279
+ )
280
+ )
281
+ steps.setdefault(key, {})[_canonical(action_class)] = tuple(schedule)
282
+ return CapsLimits(steps=steps)
283
+
284
+
285
+ def read_caps_limits(resolved: ResolvedManifest, ledger: LedgerAPI, signer: Signer) -> CapsLimits:
286
+ """``caps_limits`` over ``ledger``'s activation records as they stand now."""
287
+ return caps_limits(resolved, activation_records(ledger), signer)
@@ -0,0 +1,26 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """YAML front door for policy manifests: YAML text/file -> validated Manifest."""
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ from .errors import MALFORMED_MANIFEST, PolicyManifestError
10
+ from .manifest import Manifest, parse_manifest
11
+
12
+ __all__ = ["load_manifest_text", "load_manifest_file"]
13
+
14
+
15
+ def load_manifest_text(text: str) -> Manifest:
16
+ try:
17
+ data = yaml.safe_load(text)
18
+ except yaml.YAMLError as exc:
19
+ raise PolicyManifestError(MALFORMED_MANIFEST, f"invalid YAML: {exc}") from exc
20
+ if data is None:
21
+ raise PolicyManifestError(MALFORMED_MANIFEST, "empty manifest document")
22
+ return parse_manifest(data)
23
+
24
+
25
+ def load_manifest_file(path: str | Path) -> Manifest:
26
+ return load_manifest_text(Path(path).read_text())
@@ -0,0 +1,289 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The policy manifest: one canonical, digestible file listing the active
3
+ fold and wicket (guard-constraint) definitions, *by digest*.
4
+
5
+ This is the declare-attest-verify piece (in-toto/SLSA pattern) for guard
6
+ policy: a manifest is a lockfile, not a copy. Each entry cites a fold_id or
7
+ wicket_id plus that definition's own already-real ``definition_digest()``
8
+ (``folds/definition.py`` / ``guards/wickets/definition.py``) -- never the
9
+ definition body itself, mirroring the same "definitions-by-digest, never
10
+ definitions-by-copy" rule fold *results* already follow when they cite
11
+ ``fold_digest`` on a ledger record rather than embedding the fold. Because a
12
+ manifest is itself just declarative data, it gets a digest the exact same
13
+ way: SHA-256 over the JCS-canonical bytes of its own canonical form (see
14
+ ``manifest_digest()``), via the same ``agent_action_capsule.canonical.
15
+ json_digest`` every other digest in this codebase uses.
16
+
17
+ Whether a manifest's pinned digests actually match what's sitting in the
18
+ fold/wicket catalogs *right now* is not this module's job -- see
19
+ ``resolve.py`` for that cross-check. This module only parses and digests the
20
+ manifest as declared.
21
+
22
+ Design notes -- ``engine``:
23
+
24
+ Every entry also carries an ``engine`` string (``"fold/1"`` for folds,
25
+ ``"wicket/1"`` for wickets today) identifying which evaluator resolves that
26
+ entry's semantics, separately from the digest identifying *which
27
+ definition*. Right now there is exactly one legal value per entry kind --
28
+ this repo's own built-in fold/wicket evaluator, kept as the shipped default
29
+ -- so the field looks decorative. It isn't: it participates in
30
+ ``canonical_dict()`` (an entry that changes evaluation engine is a manifest
31
+ change, same as a changed digest) and ``resolve.py`` fails closed on any
32
+ engine value it doesn't recognize. The reasoning mirrors
33
+ ``agent_action_capsule``'s own ``digest_alg`` field on capsule refs (see
34
+ ``verify_composition.py``): ``"SHA-256"`` is the only legal value today, but
35
+ the field exists so a future algorithm can land as a new recognized value
36
+ rather than a wire-format break. Same trade here -- our evidentiary claims
37
+ ("evaluated under manifest <digest>", byte-exact CI replay, mutant-tested
38
+ determinism) are cheap to defend precisely because the wicket/fold set is
39
+ closed and tiny; adopting a general declarative engine (OPA/Rego) would
40
+ trade that for a large language surface and an engine-version-pinning
41
+ burden, so it isn't the launch engine. If one is ever adopted, Cedar is the
42
+ better fit (declarative, formally analyzed, deterministic, no I/O by
43
+ design, Apache-2.0) -- but Cedar decides authorization over entities with
44
+ no concept of aggregates, so this repo would still own the fold half
45
+ regardless. This field is what keeps that door open as a new registry
46
+ entry later instead of a manifest-format break now: "any vendor's gate" is
47
+ the intended story, since the durable value this repo provides is the
48
+ recording/envelope layer, not the constraint language.
49
+
50
+ Design notes -- prior art considered, not adopted (2026-08-05, concept-only;
51
+ no external code was read or copied as part of building this module, and no
52
+ external project names belong in this public repo, so pointers to the
53
+ private inventory of that comparison live in this project's own non-public
54
+ tracking, not here):
55
+
56
+ - A content-hash + lifecycle-state registry pattern from prior internal
57
+ tooling was flagged as a closer analog to this manifest than what was
58
+ originally scoped for this task. This module was written clean-room
59
+ against public interfaces only; the pointer for whoever does that
60
+ comparison next is tracked privately.
61
+ - A three-way clearance taxonomy (deterministic / operator-approval /
62
+ evidence-first) from separate prior internal tooling is worth evaluating
63
+ against this manifest's wicket-clearance model -- not inherited wholesale
64
+ here.
65
+ """
66
+ from __future__ import annotations
67
+
68
+ import re
69
+ from dataclasses import dataclass
70
+ from typing import Any
71
+
72
+ from agent_action_capsule.canonical import json_digest
73
+ from agent_action_capsule.contracts import is_hex64
74
+
75
+ from .errors import (
76
+ DUPLICATE_FOLD_REF,
77
+ DUPLICATE_PACK_REF,
78
+ DUPLICATE_WICKET_REF,
79
+ INVALID_DIGEST,
80
+ INVALID_MANIFEST_ID,
81
+ INVALID_PACK_MODE,
82
+ MALFORMED_MANIFEST,
83
+ PolicyManifestError,
84
+ )
85
+
86
+ __all__ = ["MANIFEST_ID_RE", "PACK_MODES", "FoldRef", "WicketRef", "PackRef", "Manifest", "parse_manifest"]
87
+
88
+ # manifest_id: same "human name + semver" shape as fold_id/wicket_id.
89
+ MANIFEST_ID_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*/\d+\.\d+\.\d+$")
90
+
91
+ # A pack ref's lifecycle state (starter-packs plan: "observe mode: records,
92
+ # no enforcement" -> "enforce: thresholds accepted by a human, gate goes
93
+ # live"). Closed set, same discipline as ``engine`` below -- an unrecognized
94
+ # mode is a typo, never silently accepted as data.
95
+ PACK_MODES = frozenset({"observe", "enforce"})
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class FoldRef:
100
+ fold_id: str
101
+ engine: str
102
+ digest: str
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class WicketRef:
107
+ wicket_id: str
108
+ engine: str
109
+ digest: str
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class PackRef:
114
+ """One installed starter pack, cited by digest like a fold/wicket ref.
115
+
116
+ ``digest`` is the pack's own ``definition_digest()`` (``packs/schema.py``
117
+ -- SHA-256 over the JCS-canonical bytes of its obligations/action-semantics/
118
+ constraint-and-fold-ref declaration), not a copy of the pack content --
119
+ same "definitions-by-digest, never definitions-by-copy" rule every other
120
+ entry in this manifest follows. ``mode`` is which lifecycle state this
121
+ pack is installed in *as of this manifest* -- flipping observe -> enforce
122
+ is itself a new manifest (a new ``PackRef.mode``), so "what was in force"
123
+ stays provable at every point in time, not just today.
124
+ """
125
+
126
+ pack_id: str
127
+ engine: str
128
+ digest: str
129
+ mode: str
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Manifest:
134
+ manifest_id: str
135
+ folds: tuple[FoldRef, ...] = ()
136
+ wickets: tuple[WicketRef, ...] = ()
137
+ packs: tuple[PackRef, ...] = ()
138
+ # The digest of the user's policy profile (``profile.py``), pinned like
139
+ # every fold and wicket: a changed limit is a changed manifest, while the
140
+ # wicket and pack it configures keep their digests.
141
+ profile_digest: str | None = None
142
+
143
+ def canonical_dict(self) -> dict:
144
+ """The JCS-canonicalizable form of this manifest -- drives manifest_digest().
145
+
146
+ List order is preserved (not sorted) and is part of what gets
147
+ digested, same as a fold definition's ``reads`` list -- editing the
148
+ manifest file (even just reordering entries) is a manifest change."""
149
+ out: dict[str, Any] = {
150
+ "manifest_id": self.manifest_id,
151
+ "folds": [{"fold_id": f.fold_id, "engine": f.engine, "digest": f.digest} for f in self.folds],
152
+ "wickets": [{"wicket_id": w.wicket_id, "engine": w.engine, "digest": w.digest} for w in self.wickets],
153
+ }
154
+ # Omitted entirely (not an empty list) when no pack is installed, so
155
+ # a pre-pack manifest's canonical_dict -- and therefore its digest --
156
+ # is byte-identical to what it was before this field existed.
157
+ if self.packs:
158
+ out["packs"] = [
159
+ {"pack_id": p.pack_id, "engine": p.engine, "digest": p.digest, "mode": p.mode} for p in self.packs
160
+ ]
161
+ # Omitted when no profile is pinned, by the same rule as ``packs``.
162
+ if self.profile_digest is not None:
163
+ out["profile_digest"] = self.profile_digest
164
+ return out
165
+
166
+ def manifest_digest(self) -> str:
167
+ """SHA-256 over the JCS bytes of the canonical manifest.
168
+
169
+ Every field in ``canonical_dict()`` is a string (ids and hex
170
+ digests), so unlike ``FoldDefinition.definition_digest()`` there is
171
+ no float/unsafe-integer failure mode to guard here -- ``json_digest``
172
+ cannot raise on an all-string payload."""
173
+ return json_digest(self.canonical_dict())
174
+
175
+
176
+ def _check_digest(value: Any, context: str) -> str:
177
+ if not isinstance(value, str) or not is_hex64(value):
178
+ raise PolicyManifestError(INVALID_DIGEST, f"{context} must be a 64-hex-char digest string, got {value!r}")
179
+ return value
180
+
181
+
182
+ def parse_manifest(data: Any) -> Manifest:
183
+ """Validate a plain dict (as loaded from YAML) into a ``Manifest``."""
184
+ if not isinstance(data, dict):
185
+ raise PolicyManifestError(MALFORMED_MANIFEST, "manifest must be a mapping")
186
+
187
+ manifest_id = data.get("manifest_id")
188
+ if not isinstance(manifest_id, str) or not MANIFEST_ID_RE.match(manifest_id):
189
+ raise PolicyManifestError(
190
+ INVALID_MANIFEST_ID,
191
+ f"manifest_id {manifest_id!r} must match '<namespace>[.<namespace>...]/<major>.<minor>.<patch>' "
192
+ "(e.g. 'default/1.0.0')",
193
+ )
194
+
195
+ raw_folds = data.get("folds") or []
196
+ if not isinstance(raw_folds, list):
197
+ raise PolicyManifestError(MALFORMED_MANIFEST, "folds must be a list")
198
+ folds: list[FoldRef] = []
199
+ seen_fold_ids: set[str] = set()
200
+ for entry in raw_folds:
201
+ if not isinstance(entry, dict) or "fold_id" not in entry or "digest" not in entry or "engine" not in entry:
202
+ raise PolicyManifestError(
203
+ MALFORMED_MANIFEST, f"each folds entry needs 'fold_id', 'engine', and 'digest': {entry!r}"
204
+ )
205
+ fold_id = entry["fold_id"]
206
+ if not isinstance(fold_id, str) or not fold_id:
207
+ raise PolicyManifestError(MALFORMED_MANIFEST, f"folds[].fold_id must be a non-empty string: {entry!r}")
208
+ if fold_id in seen_fold_ids:
209
+ raise PolicyManifestError(DUPLICATE_FOLD_REF, f"fold_id {fold_id!r} declared more than once")
210
+ seen_fold_ids.add(fold_id)
211
+ engine = entry["engine"]
212
+ if not isinstance(engine, str) or not engine:
213
+ raise PolicyManifestError(
214
+ MALFORMED_MANIFEST, f"folds[{fold_id!r}].engine must be a non-empty string: {entry!r}"
215
+ )
216
+ digest = _check_digest(entry["digest"], f"folds[{fold_id!r}].digest")
217
+ folds.append(FoldRef(fold_id=fold_id, engine=engine, digest=digest))
218
+
219
+ raw_wickets = data.get("wickets") or []
220
+ if not isinstance(raw_wickets, list):
221
+ raise PolicyManifestError(MALFORMED_MANIFEST, "wickets must be a list")
222
+ wickets: list[WicketRef] = []
223
+ seen_wicket_ids: set[str] = set()
224
+ for entry in raw_wickets:
225
+ if not isinstance(entry, dict) or "wicket_id" not in entry or "digest" not in entry or "engine" not in entry:
226
+ raise PolicyManifestError(
227
+ MALFORMED_MANIFEST, f"each wickets entry needs 'wicket_id', 'engine', and 'digest': {entry!r}"
228
+ )
229
+ wicket_id = entry["wicket_id"]
230
+ if not isinstance(wicket_id, str) or not wicket_id:
231
+ raise PolicyManifestError(MALFORMED_MANIFEST, f"wickets[].wicket_id must be a non-empty string: {entry!r}")
232
+ if wicket_id in seen_wicket_ids:
233
+ raise PolicyManifestError(DUPLICATE_WICKET_REF, f"wicket_id {wicket_id!r} declared more than once")
234
+ seen_wicket_ids.add(wicket_id)
235
+ engine = entry["engine"]
236
+ if not isinstance(engine, str) or not engine:
237
+ raise PolicyManifestError(
238
+ MALFORMED_MANIFEST, f"wickets[{wicket_id!r}].engine must be a non-empty string: {entry!r}"
239
+ )
240
+ digest = _check_digest(entry["digest"], f"wickets[{wicket_id!r}].digest")
241
+ wickets.append(WicketRef(wicket_id=wicket_id, engine=engine, digest=digest))
242
+
243
+ raw_packs = data.get("packs") or []
244
+ if not isinstance(raw_packs, list):
245
+ raise PolicyManifestError(MALFORMED_MANIFEST, "packs must be a list")
246
+ packs: list[PackRef] = []
247
+ seen_pack_ids: set[str] = set()
248
+ for entry in raw_packs:
249
+ if (
250
+ not isinstance(entry, dict)
251
+ or "pack_id" not in entry
252
+ or "digest" not in entry
253
+ or "engine" not in entry
254
+ or "mode" not in entry
255
+ ):
256
+ raise PolicyManifestError(
257
+ MALFORMED_MANIFEST,
258
+ f"each packs entry needs 'pack_id', 'engine', 'digest', and 'mode': {entry!r}",
259
+ )
260
+ pack_id = entry["pack_id"]
261
+ if not isinstance(pack_id, str) or not pack_id:
262
+ raise PolicyManifestError(MALFORMED_MANIFEST, f"packs[].pack_id must be a non-empty string: {entry!r}")
263
+ if pack_id in seen_pack_ids:
264
+ raise PolicyManifestError(DUPLICATE_PACK_REF, f"pack_id {pack_id!r} declared more than once")
265
+ seen_pack_ids.add(pack_id)
266
+ engine = entry["engine"]
267
+ if not isinstance(engine, str) or not engine:
268
+ raise PolicyManifestError(
269
+ MALFORMED_MANIFEST, f"packs[{pack_id!r}].engine must be a non-empty string: {entry!r}"
270
+ )
271
+ mode = entry["mode"]
272
+ if mode not in PACK_MODES:
273
+ raise PolicyManifestError(
274
+ INVALID_PACK_MODE, f"packs[{pack_id!r}].mode must be one of {sorted(PACK_MODES)}, got {mode!r}"
275
+ )
276
+ digest = _check_digest(entry["digest"], f"packs[{pack_id!r}].digest")
277
+ packs.append(PackRef(pack_id=pack_id, engine=engine, digest=digest, mode=mode))
278
+
279
+ profile_digest = data.get("profile_digest")
280
+ if profile_digest is not None:
281
+ _check_digest(profile_digest, "profile_digest")
282
+
283
+ return Manifest(
284
+ manifest_id=manifest_id,
285
+ folds=tuple(folds),
286
+ wickets=tuple(wickets),
287
+ packs=tuple(packs),
288
+ profile_digest=profile_digest,
289
+ )