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,517 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """caps check: a fold predicate over T1's fold engine.
3
+
4
+ ``weekly_spend + amount <= cap``, where ``weekly_spend`` is the T1 fold's
5
+ own evaluated result (a real replay over the ledger, never a number the
6
+ guard computes itself) and ``amount`` is this action's own, not-yet-
7
+ recorded amount. Records that carry no amount (imported/foreign capsules,
8
+ or non-money actions) are skipped by the fold engine itself (spec §3
9
+ rule 4), not treated as zero-contributing noise by this check.
10
+
11
+ The running total is partitioned by the fold's own ``key`` (``developer`` or
12
+ ``operator``), and the evidence names that key and the value the total was
13
+ read under. ``caps_minor`` is keyed by action class; ``resolve_caps_minor``
14
+ maps a legacy name to its canonical row so both spellings are one cap.
15
+
16
+ A class may also carry a per-action limit, compared against the proposed
17
+ action's own amount alone. With one, the evidence adds ``per_action_cap_minor``
18
+ and ``tripped``: one ``{limit, threshold_minor, observed_minor}`` entry per
19
+ limit exceeded (``per_action`` against the amount, ``window`` against the
20
+ projected total), empty on a pass. Without one the evidence keeps its
21
+ single-limit shape, so records under a window-only config keep their bytes.
22
+
23
+ Under a config with ``per_action_reads: spend_authorized_minor``
24
+ (``caps/5.0.0``) the per-action limit reads the action's authorised maximum,
25
+ ``spend_authorized_minor``: the most the payment may take, which can exceed
26
+ the expected capture by a pre-authorisation buffer. It reads the capture,
27
+ ``spend_minor`` (the action's ``amount_minor``), when no authorised maximum was
28
+ declared, or one below the capture was. The window limit always reads the
29
+ capture, because the rolling total sums what was taken. The evidence adds
30
+ ``per_action_basis``: the field read, whether it fell back to the capture, and
31
+ the value compared.
32
+
33
+ When the engine reads its limits from activated policy (``policy/limits.py``)
34
+ the evidence adds ``limit_sources``: for each limit applied (``window``, and
35
+ ``per_action`` when set), whether its value is the operator's, from a policy
36
+ profile (``operator_profile``, with that profile's digest), or the wicket's
37
+ default (``definition_default``), plus any raise activated but still inside
38
+ its cooling-off (``pending_raise``). An engine given bare limit tables has no
39
+ provenance to report, and its evidence keeps the shape it had.
40
+
41
+ Under a fold with a ``reversal`` clause (``spend.weekly/3.0.0``) the evidence
42
+ adds ``reversals``: how many cancels or refunds took a linked charge back out
43
+ of the total and by how much, and how many were unlinked and did nothing.
44
+ Under a fold without one the evidence keeps the shape it had.
45
+
46
+ Under a fold that also counts executed acts (``spend.weekly/3.1.0``, whose
47
+ filter admits ``EXECUTED_DECISION``) a replay, or a live check's history
48
+ ledger, writes a record for each executed act (``report/replay.py``,
49
+ ``ExecutedActs``). One whose spend could not be read carries
50
+ ``asg_payload.spend_unreadable`` and no amount, so the fold skips it. With one
51
+ in the window the total is not known: the check is ``n/a``, in scope, naming
52
+ ``spend_minor``, with the reason ``WINDOW_UNREADABLE_REASON``, and the engine
53
+ asks rather than allows (``CheckOutcome.asks_when_unevaluated``). A per-action
54
+ limit the action exceeds still fails, and its evidence adds
55
+ ``window_unreadable_count``.
56
+
57
+ A live check's history ledger that is not known to hold every act writes an
58
+ ``incomplete`` record (``guards/history_state.py``) under the checked
59
+ record's operator. With one in the ledger the window total may leave out acts
60
+ that were carried out: the check is ``n/a``, in scope, with the reason
61
+ ``history_incomplete_reason`` and no missing field, and the engine asks
62
+ rather than allows, under any fold. It takes precedence over
63
+ ``WINDOW_UNREADABLE_REASON``. A per-action limit the action exceeds still
64
+ fails, its evidence unchanged. A ledger with no such record is read as
65
+ before.
66
+ """
67
+ from __future__ import annotations
68
+
69
+ from collections.abc import Mapping
70
+ from dataclasses import replace
71
+ from typing import Literal, NotRequired, TypedDict
72
+
73
+ from cll.ledger.api import LedgerAPI, ScanQuery
74
+
75
+ from ...folds.definition import FilterClause, FoldDefinition, ReadField, Reduce
76
+ from ...folds.engine import EvaluationTrace, ReversalSummary, evaluate_one
77
+ from ..action import Action
78
+ from ..capsule import ConstraintOutcome, not_applicable_evidence
79
+ from ..classes import resolve
80
+ from ..history_state import INCOMPLETE, WINDOW_HELD, history_state
81
+ from .base import CheckOutcome
82
+
83
+ __all__ = [
84
+ "EXECUTED_DECISION",
85
+ "SPEND_UNREADABLE",
86
+ "WINDOW_UNREADABLE_REASON",
87
+ "LIMIT_SOURCE_DEFAULT",
88
+ "LIMIT_SOURCE_PROFILE",
89
+ "PER_ACTION_READS",
90
+ "CapTripped",
91
+ "CapsEvidence",
92
+ "FoldKey",
93
+ "LimitSource",
94
+ "LimitSources",
95
+ "PendingRaise",
96
+ "PerActionBasis",
97
+ "TwoLimitCapsEvidence",
98
+ "cap_for",
99
+ "check_caps",
100
+ "counts_executed_acts",
101
+ "history_incomplete_reason",
102
+ "in_window_count",
103
+ "require_per_action_reads",
104
+ "resolve_caps_minor",
105
+ ]
106
+
107
+ # The values a caps config's ``per_action_reads`` may take. Absent, the
108
+ # per-action limit reads the capture, as caps/3.0.0 and caps/4.0.0 do.
109
+ PER_ACTION_READS = frozenset({"spend_authorized_minor"})
110
+
111
+ # Where a limit's value came from (``limit_sources``).
112
+ LIMIT_SOURCE_PROFILE = "operator_profile"
113
+ LIMIT_SOURCE_DEFAULT = "definition_default"
114
+
115
+ # The ``disposition.decision`` of the record a replay writes for an act a
116
+ # deal executed. No guard decision carries it.
117
+ EXECUTED_DECISION = "executed"
118
+ # The ``asg_payload`` flag on such a record whose spend could not be read.
119
+ SPEND_UNREADABLE = "spend_unreadable"
120
+ WINDOW_UNREADABLE_REASON = "an earlier act has no recorded decision, so the total cannot count it"
121
+ _DECISION_PATH = "disposition.decision"
122
+ _UNREADABLE_PATH = f"asg_payload.{SPEND_UNREADABLE}"
123
+
124
+
125
+ class FoldKey(TypedDict):
126
+ """The partition a running total was read under."""
127
+
128
+ path: str | None
129
+ value: str | None
130
+
131
+
132
+ class PendingRaise(TypedDict):
133
+ """A higher value activated for a limit, not yet in force."""
134
+
135
+ value_minor: int
136
+ effective_at: str
137
+ limit_source: Literal["operator_profile", "definition_default"]
138
+ profile_digest: NotRequired[str]
139
+
140
+
141
+ class LimitSource(TypedDict):
142
+ """Where one applied limit's value came from."""
143
+
144
+ limit_source: Literal["operator_profile", "definition_default"]
145
+ # Only for ``operator_profile``: the digest of the profile that set it.
146
+ profile_digest: NotRequired[str]
147
+ pending_raise: NotRequired[PendingRaise]
148
+
149
+
150
+ class LimitSources(TypedDict):
151
+ """``LimitSource`` per applied limit."""
152
+
153
+ window: LimitSource
154
+ per_action: NotRequired[LimitSource]
155
+
156
+
157
+ class CapsEvidence(TypedDict):
158
+ """The facts a pass/fail caps constraint seals."""
159
+
160
+ fold: str
161
+ fold_key: FoldKey
162
+ weekly_spend_minor: int
163
+ amount_minor: int
164
+ cap_minor: int
165
+ projected_minor: int
166
+ # Only under a fold with a ``reversal`` clause.
167
+ reversals: NotRequired[ReversalSummary]
168
+ # Only when the engine reads its limits from activated policy.
169
+ limit_sources: NotRequired[LimitSources]
170
+ # Only when an executed act in the window has a spend that cannot be read.
171
+ window_unreadable_count: NotRequired[int]
172
+
173
+
174
+ class CapTripped(TypedDict):
175
+ """One limit an action exceeded, as sealed in the caps evidence."""
176
+
177
+ limit: Literal["per_action", "window"]
178
+ threshold_minor: int
179
+ observed_minor: int
180
+
181
+
182
+ class PerActionBasis(TypedDict):
183
+ """The amount a per-action limit read, under ``per_action_reads``."""
184
+
185
+ field: Literal["spend_authorized_minor", "spend_minor"]
186
+ fell_back: bool
187
+ observed_minor: int
188
+
189
+
190
+ class TwoLimitCapsEvidence(CapsEvidence):
191
+ """``CapsEvidence`` under a config that also sets a per-action limit."""
192
+
193
+ per_action_cap_minor: int
194
+ tripped: list[CapTripped]
195
+ # Only under a config with ``per_action_reads``.
196
+ per_action_basis: NotRequired[PerActionBasis]
197
+
198
+
199
+ def _canonical(action_class: str) -> str:
200
+ ac = resolve(action_class)
201
+ return ac.name if ac is not None else action_class
202
+
203
+
204
+ def resolve_caps_minor(caps_minor: Mapping[str, int]) -> dict[str, int]:
205
+ """``caps_minor`` keyed by canonical class name. A name outside the
206
+ taxonomy is kept as written. Raises ``ValueError`` when a legacy and a
207
+ canonical key for one class carry different limits."""
208
+ out: dict[str, int] = {}
209
+ for name, cap in caps_minor.items():
210
+ canonical = _canonical(name)
211
+ if canonical in out and out[canonical] != cap:
212
+ raise ValueError(
213
+ f"caps_minor sets two limits for {canonical!r} ({out[canonical]} and {cap}) "
214
+ "under a legacy and a canonical name"
215
+ )
216
+ out[canonical] = cap
217
+ return out
218
+
219
+
220
+ def cap_for(caps_minor: Mapping[str, int], action_class: str | None) -> int | None:
221
+ """The limit for ``action_class`` in a ``resolve_caps_minor`` mapping."""
222
+ if action_class is None:
223
+ return None
224
+ return caps_minor.get(_canonical(action_class))
225
+
226
+
227
+ def _fold_key(action: Action, key: str | None, since: str | None) -> tuple[str | None, ScanQuery]:
228
+ """The action's value for the fold's partition key, and a scan narrowed to it.
229
+
230
+ ``ScanQuery.counterparty`` is the ledger's filter on ``operator``.
231
+ """
232
+ if key == "developer":
233
+ return action.developer, ScanQuery(agent=action.developer, since=since)
234
+ if key == "operator":
235
+ return action.operator, ScanQuery(counterparty=action.operator, since=since)
236
+ if key is None:
237
+ return None, ScanQuery(since=since)
238
+ raise ValueError(f"caps cannot partition by fold key {key!r}; it reads developer or operator")
239
+
240
+
241
+ def counts_executed_acts(definition: FoldDefinition | None) -> bool:
242
+ """Whether ``definition``'s filter admits the record a replay writes for
243
+ an executed act (``EXECUTED_DECISION``)."""
244
+ if definition is None:
245
+ return False
246
+ for clause in definition.filter:
247
+ if clause.field != _DECISION_PATH:
248
+ continue
249
+ if (clause.op == "eq" and clause.value == EXECUTED_DECISION) or (
250
+ clause.op == "in" and EXECUTED_DECISION in clause.value
251
+ ):
252
+ return True
253
+ return False
254
+
255
+
256
+ def _unreadable_in_window(definition: FoldDefinition, records: list[dict], key_value: str | None, as_of: str) -> int:
257
+ """How many executed acts with a spend that could not be read fall in
258
+ ``definition``'s window under ``key_value``: the same key, window and
259
+ anchor the total was read under, counted instead of summed."""
260
+ return _count_in_window(
261
+ definition, records, key_value, as_of,
262
+ reads=(ReadField(path=_DECISION_PATH, erasure_class="commitment-ok"),
263
+ ReadField(path=_UNREADABLE_PATH, erasure_class="commitment-ok", default=False, has_default=True)),
264
+ filter=(FilterClause(field=_DECISION_PATH, op="eq", value=EXECUTED_DECISION),
265
+ FilterClause(field=_UNREADABLE_PATH, op="eq", value=True)),
266
+ )
267
+
268
+
269
+ def in_window_count(definition: FoldDefinition, records: list[dict], key_value: str | None, as_of: str) -> int:
270
+ """How many of ``records`` the window would count by their key and time
271
+ alone: under ``key_value`` for ``definition``'s key, in its window as of
272
+ ``as_of``, read as the total is read."""
273
+ return _count_in_window(definition, records, key_value, as_of, reads=(), filter=())
274
+
275
+
276
+ def _count_in_window(definition: FoldDefinition, records: list[dict], key_value: str | None, as_of: str, *,
277
+ reads: tuple[ReadField, ...], filter: tuple[FilterClause, ...]) -> int:
278
+ """How many of ``records`` pass ``filter`` (over ``reads``) and fall in
279
+ ``definition``'s window under ``key_value``, counted instead of summed."""
280
+ probe_reads = [ReadField(path="timestamp", erasure_class="commitment-ok"), *reads]
281
+ if definition.key is not None:
282
+ probe_reads.append(ReadField(path=definition.key, erasure_class="commitment-ok"))
283
+ probe = replace(definition, reads=tuple(probe_reads), filter=filter, reduce=Reduce(reducer="count"), reversal=None)
284
+ return evaluate_one(probe, records, key_value=key_value, as_of=as_of).result or 0
285
+
286
+
287
+ def _operator_history_incomplete(ledger: LedgerAPI, action: Action) -> bool:
288
+ """Whether ``action``'s operator's records hold a live history's
289
+ ``incomplete`` record, which is written under the checked record's
290
+ operator."""
291
+ return any(history_state(r.capsule) == INCOMPLETE for r in ledger.scan(ScanQuery(counterparty=action.operator)))
292
+
293
+
294
+ def _window_held(records: list[dict]) -> str | None:
295
+ """The reason a live history's ``window_held`` record gives, when one of
296
+ ``records`` is that record."""
297
+ for capsule in records:
298
+ if history_state(capsule) == WINDOW_HELD:
299
+ reason = (capsule.get("asg_payload") or {}).get("held_reason")
300
+ return reason if isinstance(reason, str) and reason else "the history cannot decide the window"
301
+ return None
302
+
303
+
304
+ def history_incomplete_reason(definition: FoldDefinition) -> str:
305
+ """Why the window total is not known on a history that may be missing
306
+ acts, naming the fold's window (``7d``)."""
307
+ window = definition.window.duration if definition.window is not None and definition.window.duration else "window"
308
+ return (
309
+ "the history supplied is incomplete (more acts than it holds, or some could not be read), "
310
+ f"so the {window} total cannot be computed"
311
+ )
312
+
313
+
314
+ def require_per_action_reads(per_action_reads: str | None) -> None:
315
+ """Raise ``ValueError`` unless ``per_action_reads`` is absent or one of
316
+ ``PER_ACTION_READS``."""
317
+ if per_action_reads is not None and per_action_reads not in PER_ACTION_READS:
318
+ raise ValueError(f"caps per_action_reads {per_action_reads!r} is not one of {sorted(PER_ACTION_READS)}")
319
+
320
+
321
+ def _per_action_basis(action: Action, amount_minor: int) -> PerActionBasis:
322
+ """What the per-action limit compares under ``per_action_reads``. An
323
+ authorised maximum below the capture is not a maximum, so the capture is
324
+ read instead."""
325
+ authorized = action.spend_authorized_minor
326
+ if authorized is None or authorized < amount_minor:
327
+ return PerActionBasis(field="spend_minor", fell_back=True, observed_minor=amount_minor)
328
+ return PerActionBasis(field="spend_authorized_minor", fell_back=False, observed_minor=authorized)
329
+
330
+
331
+ def check_caps(
332
+ action: Action,
333
+ ledger: LedgerAPI,
334
+ *,
335
+ definition: FoldDefinition,
336
+ cap_minor: int,
337
+ per_action_cap_minor: int | None = None,
338
+ per_action_reads: str | None = None,
339
+ since: str | None = None,
340
+ as_of: str | None = None,
341
+ limit_sources: LimitSources | None = None,
342
+ ) -> CheckOutcome:
343
+ require_per_action_reads(per_action_reads)
344
+ if limit_sources is not None and ("per_action" in limit_sources) != (per_action_cap_minor is not None):
345
+ raise ValueError("limit_sources must name a per_action source exactly when a per-action limit is applied")
346
+ if action.amount_minor is None:
347
+ return CheckOutcome(
348
+ constraint=ConstraintOutcome(
349
+ id="caps",
350
+ result="n/a",
351
+ reason="a cap is configured for this action class but the action carries no amount_minor; "
352
+ "the cap could not be evaluated",
353
+ evidence=not_applicable_evidence("caps", in_scope=True, missing_field="amount_minor"),
354
+ check_type="policy",
355
+ method=definition.fold_id,
356
+ )
357
+ )
358
+
359
+ key_value, query = _fold_key(action, definition.key, since)
360
+ records = [r.capsule for r in ledger.scan(query)]
361
+ anchor = as_of or action.resolved_timestamp()
362
+ trace = evaluate_one(definition, records, key_value=key_value, as_of=anchor)
363
+ unreadable = _unreadable_in_window(definition, records, key_value, anchor) if counts_executed_acts(definition) else 0
364
+ # Under a fold keyed by operator the records read already hold the operator's marker.
365
+ incomplete = (any(history_state(capsule) == INCOMPLETE for capsule in records) if definition.key == "operator"
366
+ else _operator_history_incomplete(ledger, action))
367
+ held = _window_held(records if definition.key == "operator"
368
+ else [r.capsule for r in ledger.scan(ScanQuery(counterparty=action.operator))])
369
+ weekly_spend = trace.result or 0
370
+ projected = weekly_spend + action.amount_minor
371
+ envelope = trace.to_envelope()
372
+ evidence = CapsEvidence(
373
+ fold=envelope["fold"],
374
+ fold_key=FoldKey(path=definition.key, value=key_value),
375
+ weekly_spend_minor=weekly_spend,
376
+ amount_minor=action.amount_minor,
377
+ cap_minor=cap_minor,
378
+ projected_minor=projected,
379
+ )
380
+
381
+ if trace.reversals is not None:
382
+ evidence["reversals"] = trace.reversals
383
+ if limit_sources is not None:
384
+ evidence["limit_sources"] = limit_sources
385
+ if unreadable:
386
+ evidence["window_unreadable_count"] = unreadable
387
+
388
+ if per_action_cap_minor is not None:
389
+ basis = _per_action_basis(action, action.amount_minor) if per_action_reads is not None else None
390
+ per_action_amount = basis["observed_minor"] if basis is not None else action.amount_minor
391
+ result, reason, tripped = _judge_two_limits(
392
+ per_action_amount, projected, cap_minor, per_action_cap_minor, basis["field"] if basis is not None else "amount"
393
+ )
394
+ if not any(t["limit"] == "per_action" for t in tripped):
395
+ if incomplete:
396
+ return _history_incomplete_outcome(definition, trace)
397
+ if held is not None:
398
+ return _held_outcome(definition, trace, held)
399
+ if unreadable:
400
+ return _window_unreadable(definition, envelope)
401
+ two_limit = TwoLimitCapsEvidence(**evidence, per_action_cap_minor=per_action_cap_minor, tripped=tripped)
402
+ if basis is not None:
403
+ two_limit["per_action_basis"] = basis
404
+ return CheckOutcome(
405
+ constraint=ConstraintOutcome(
406
+ id="caps",
407
+ result=result,
408
+ reason=reason,
409
+ evidence=two_limit,
410
+ check_type="policy",
411
+ method=definition.fold_id,
412
+ ),
413
+ fold_envelopes=(envelope,),
414
+ )
415
+
416
+ if incomplete:
417
+ return _history_incomplete_outcome(definition, trace)
418
+ if held is not None:
419
+ return _held_outcome(definition, trace, held)
420
+ if unreadable:
421
+ return _window_unreadable(definition, envelope)
422
+ if projected <= cap_minor:
423
+ return CheckOutcome(
424
+ constraint=ConstraintOutcome(
425
+ id="caps",
426
+ result="pass",
427
+ reason=f"weekly spend {weekly_spend} + {action.amount_minor} <= cap {cap_minor} (minor units)",
428
+ evidence=evidence,
429
+ check_type="policy",
430
+ method=definition.fold_id,
431
+ ),
432
+ fold_envelopes=(envelope,),
433
+ )
434
+ return CheckOutcome(
435
+ constraint=ConstraintOutcome(
436
+ id="caps",
437
+ result="fail",
438
+ reason=f"weekly spend {weekly_spend} + {action.amount_minor} exceeds cap {cap_minor} (minor units)",
439
+ evidence=evidence,
440
+ check_type="policy",
441
+ method=definition.fold_id,
442
+ ),
443
+ fold_envelopes=(envelope,),
444
+ )
445
+
446
+
447
+ def _window_unreadable(definition: FoldDefinition, envelope: dict) -> CheckOutcome:
448
+ """``n/a`` in scope, naming ``spend_minor``: an executed act in the
449
+ window has a spend that cannot be read, so the total is not known. The
450
+ engine asks rather than allows (``CheckOutcome.asks_when_unevaluated``)."""
451
+ return CheckOutcome(
452
+ constraint=ConstraintOutcome(
453
+ id="caps",
454
+ result="n/a",
455
+ reason=WINDOW_UNREADABLE_REASON,
456
+ evidence=not_applicable_evidence("caps", in_scope=True, missing_field="spend_minor"),
457
+ check_type="policy",
458
+ method=definition.fold_id,
459
+ ),
460
+ fold_envelopes=(envelope,),
461
+ asks_when_unevaluated=True,
462
+ )
463
+
464
+
465
+ def _history_incomplete_outcome(definition: FoldDefinition, trace: EvaluationTrace) -> CheckOutcome:
466
+ """``n/a`` in scope, no missing field: the history may be missing acts,
467
+ so the total is not known. The engine asks rather than allows."""
468
+ return CheckOutcome(
469
+ constraint=ConstraintOutcome(
470
+ id="caps",
471
+ result="n/a",
472
+ reason=history_incomplete_reason(definition),
473
+ evidence=not_applicable_evidence("caps", in_scope=True),
474
+ check_type="policy",
475
+ method=definition.fold_id,
476
+ ),
477
+ fold_envelopes=(trace.to_envelope(),),
478
+ asks_when_unevaluated=True,
479
+ )
480
+
481
+
482
+ def _held_outcome(definition: FoldDefinition, trace: EvaluationTrace, reason: str) -> CheckOutcome:
483
+ """``n/a`` in scope, no missing field: a complete history the window
484
+ cannot be summed over (``window_held``), so the total is not known. The
485
+ engine asks rather than allows."""
486
+ return CheckOutcome(
487
+ constraint=ConstraintOutcome(
488
+ id="caps",
489
+ result="n/a",
490
+ reason=reason,
491
+ evidence=not_applicable_evidence("caps", in_scope=True),
492
+ check_type="policy",
493
+ method=definition.fold_id,
494
+ ),
495
+ fold_envelopes=(trace.to_envelope(),),
496
+ asks_when_unevaluated=True,
497
+ )
498
+
499
+
500
+ def _judge_two_limits(
501
+ amount: int, projected: int, cap_minor: int, per_action_cap_minor: int, amount_label: str
502
+ ) -> tuple[str, str, list[CapTripped]]:
503
+ tripped: list[CapTripped] = []
504
+ if amount > per_action_cap_minor:
505
+ tripped.append(CapTripped(limit="per_action", threshold_minor=per_action_cap_minor, observed_minor=amount))
506
+ if projected > cap_minor:
507
+ tripped.append(CapTripped(limit="window", threshold_minor=cap_minor, observed_minor=projected))
508
+ if tripped:
509
+ reason = "; ".join(
510
+ f"{t['limit']} limit {t['threshold_minor']} exceeded by {t['observed_minor']} (minor units)" for t in tripped
511
+ )
512
+ return "fail", reason, tripped
513
+ reason = (
514
+ f"{amount_label} {amount} <= per-action limit {per_action_cap_minor}; "
515
+ f"projected {projected} <= window limit {cap_minor} (minor units)"
516
+ )
517
+ return "pass", reason, tripped
@@ -0,0 +1,64 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """channel_change check: compare the channel a counterparty is using with
3
+ the one the relationship started on, both read off the same action.
4
+
5
+ ``Action.channel`` is the channel KIND in use at this action and
6
+ ``Action.first_contact_channel`` the kind the relationship started on
7
+ (``email``, ``sms``, ...), never an address. They are compared for equality.
8
+ A value outside the pinned ``seeded_channels`` (a namespaced one) is compared
9
+ the same way, and the evidence records whether each was seeded. No history is
10
+ read: the producer states both values on the one record. Applies only to the
11
+ configured ``action_classes``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import TypedDict
16
+
17
+ from ..action import Action
18
+ from ..capsule import ConstraintOutcome, NotApplicableEvidence, not_applicable_evidence
19
+ from .base import CheckOutcome
20
+
21
+ __all__ = ["check_channel_change"]
22
+
23
+ _CHECK_ID = "channel_change"
24
+ _METHOD = "same_record_v0"
25
+
26
+
27
+ class ChannelEvidence(TypedDict):
28
+ channel: str
29
+ first_contact_channel: str
30
+ channel_seeded: bool
31
+ first_contact_channel_seeded: bool
32
+
33
+
34
+ def _outcome(result: str, reason: str, evidence: ChannelEvidence | NotApplicableEvidence) -> CheckOutcome:
35
+ return CheckOutcome(
36
+ constraint=ConstraintOutcome(
37
+ id=_CHECK_ID, result=result, reason=reason, evidence=evidence, check_type="policy", method=_METHOD
38
+ )
39
+ )
40
+
41
+
42
+ def _missing(name: str) -> CheckOutcome:
43
+ return _outcome("n/a", f"the action carries no {name}; the channels could not be compared",
44
+ not_applicable_evidence(_CHECK_ID, in_scope=True, missing_field=name))
45
+
46
+
47
+ def check_channel_change(action: Action, *, seeded_channels: list[str], action_classes: list[str]) -> CheckOutcome:
48
+ if action.action_class not in action_classes:
49
+ return _outcome("n/a", "the rule is not configured for this action class",
50
+ not_applicable_evidence(_CHECK_ID, in_scope=False))
51
+ if action.channel is None:
52
+ return _missing("channel")
53
+ if action.first_contact_channel is None:
54
+ return _missing("first_contact_channel")
55
+ evidence = ChannelEvidence(
56
+ channel=action.channel,
57
+ first_contact_channel=action.first_contact_channel,
58
+ channel_seeded=action.channel in seeded_channels,
59
+ first_contact_channel_seeded=action.first_contact_channel in seeded_channels,
60
+ )
61
+ if action.channel != action.first_contact_channel:
62
+ return _outcome("fail", f"the counterparty moved from {action.first_contact_channel!r} to {action.channel!r}",
63
+ evidence)
64
+ return _outcome("pass", "the counterparty is on the channel the relationship started on", evidence)
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Opening a salted commitment a deal record seals (commit_alg
3
+ ``sha256-jcs-nonce256``): SHA-256 over the JCS bytes of ``{"nonce", "text"}``,
4
+ with a 256-bit nonce in lowercase hex. capsulectl seals every ``*_commitment``
5
+ this way (capsule-cli ``internal/cli/deal_ids.go``, ``CommitText``).
6
+
7
+ ``price_floor`` opens the task authority's ``bounds_commitment`` with the
8
+ commercial-bounds document as the text; ``promise_never`` opens a represent
9
+ check's ``representing.text_commitment`` with the words it is about to say.
10
+ Nothing an opening holds is read until it opens the sealed commitment.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from agent_action_capsule.canonical import json_digest
17
+
18
+ __all__ = ["opens_commitment", "text_commitment"]
19
+
20
+ _HEX64 = re.compile(r"[0-9a-f]{64}")
21
+
22
+
23
+ def text_commitment(nonce: str, text: str) -> str:
24
+ """The commitment to ``text`` under ``nonce``."""
25
+ return json_digest({"nonce": nonce, "text": text})
26
+
27
+
28
+ def opens_commitment(nonce: object, text: object, sealed: object) -> bool:
29
+ """Whether ``nonce`` and ``text``, decoded JSON from a checker input,
30
+ open ``sealed``: a nonce of 64 lowercase hex, a string text, a sealed
31
+ commitment of 64 lowercase hex, and the commitment they recompute equal
32
+ to it."""
33
+ if not isinstance(nonce, str) or not _HEX64.fullmatch(nonce) or not isinstance(text, str):
34
+ return False
35
+ if not isinstance(sealed, str) or not _HEX64.fullmatch(sealed):
36
+ return False
37
+ try:
38
+ return text_commitment(nonce, text) == sealed
39
+ # A text holding a lone surrogate has no JCS bytes, so it opens nothing.
40
+ except UnicodeEncodeError:
41
+ return False