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,1677 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Replay a JSONL capsule ledger through T3's ``GuardEngine`` in dry-run mode.
3
+
4
+ Every decision here comes from actually running ``GuardEngine.check(...,
5
+ dry_run=True)`` over the real fixture/ledger records -- this module never
6
+ fabricates an outcome. The guard's own replay-local view (an ephemeral
7
+ ``LedgerStore``) accumulates the decision capsules this replay itself
8
+ produces, so a dedupe hit fires the same way it would in ``test_guard_dry_run.py``:
9
+ against a *prior decision in this same replay*, not against the source
10
+ ledger's original capsules (which the guard never produced and has no
11
+ decision-history relationship to).
12
+
13
+ ``_bridge_deal_check`` builds the action of a capsulectl deal check from the
14
+ check's own sealed record, disclosed beside its capsule in a capsulectl deal
15
+ bundle: the record names the action's taxonomy class (``action_class``,
16
+ ``taxonomy_version``) and the amount a spend cap evaluates (``spend_minor``),
17
+ and the capsule binds the record by digest
18
+ (``model_attestation.compute_attestation.agent_input_digest``). The
19
+ counterparty's keyed fingerprints the record seals (``x-deal-v0.counterparty``
20
+ beside its body, or a typed record's ``body.counterparty``) are carried as
21
+ they are, never a clear value. A record
22
+ that does not match that digest is never read.
23
+
24
+ The action's target is the payee's per-deal fingerprint unless the payee is
25
+ also keyed per profile (``hmac-sha256-profile-key``, one value for one merchant
26
+ across a profile's deals): live, from ``record.counterparty_profile`` in an
27
+ external-check-input/v0 envelope (``action_for_check_input``); in a replay,
28
+ from the check's companion, a bound ``counterparty_profile`` record whose one
29
+ ``about`` ref is the check's record digest. The companion states no act. A
30
+ block that is not ``{fp_alg: "hmac-sha256-profile-key", ids: {payee: <64
31
+ lowercase hex>}}`` is ignored, named in ``Action.ignored_inputs``, and the
32
+ per-deal target stands. A decision sealed with the per-deal target is never
33
+ rewritten, so it never matches one keyed per profile.
34
+
35
+ An act record seals no counterparty, so a live check's history act takes the
36
+ target its check got: capsulectl gives the block that check sealed beside the
37
+ act (``counterparty``), and it is read as the check's own block is, the
38
+ profile-keyed payee still replacing it. A block that is not a check's
39
+ (``_check_counterparty``) is ignored and named in ``Action.ignored_inputs``.
40
+
41
+ On a check of a sale's thread, the envelope's top-level ``item_ref`` (256
42
+ random bits, lowercase hex, equal across the sale's threads) becomes
43
+ ``Action.item_ref`` for ``single_commitment``. No deal record carries it, so
44
+ it is read only live, never from a deal record in a replay. A value in any
45
+ other shape is ignored and named in ``Action.ignored_inputs``. A live check
46
+ reads its history's acts through ``action_for_history_entry``
47
+ (``report/live_history.py``). A replay of a sale bundle keys the sale on
48
+ the digest of the sale's own task authority instead, and gives
49
+ ``single_commitment`` each check's history of the sale's threads, read by the
50
+ reader a live check's history is read by (``report/sale_bundle.py``).
51
+ ``single_commitment`` reads it whole; a ``release_on_acceptance`` that binds
52
+ the acceptance from the history (1.2.0) reads it cut to the live window
53
+ (``RULES_HISTORY_DAYS``). A cited ``release_on_acceptance`` (1.0.0, 1.1.0)
54
+ still reads the replay's own view, where no acceptance is sealed, so it
55
+ releases nothing. Outside a sale replay (a dry-run report of a deal's own
56
+ records, say) a 1.2.0 ``release_on_acceptance`` reads the replay's own view
57
+ too: its records carry no ``thread_counterparty``, so every share that needs
58
+ the acceptance refuses there, which fails closed. A sale replay is as close
59
+ to live as a bundle of one sale can get, not exact: a live history is
60
+ profile-wide and capped at 1,000 acts, and past the cap it reads
61
+ incomplete, so the acceptance is unknown. A share refused live for a
62
+ truncated history may therefore pass on replay of a complete bundle.
63
+
64
+ ``_bridge_transfer_funds`` is the other non-default action mapping, and it
65
+ mirrors the pattern already established by ``tests/test_guard_dry_run.py``
66
+ and ``tests/test_guard_eur150k_bridge.py``: ``transfer_funds`` capsules in
67
+ these fixtures carry their amount only as free text inside
68
+ ``model_attestation.compute_attestation.note`` (a field this schema version
69
+ has no structured place for), so the bridge regexes the real embedded
70
+ values out of that note rather than hardcoding them.
71
+ """
72
+ from __future__ import annotations
73
+
74
+ import json
75
+ import re
76
+ import tempfile
77
+ from collections.abc import Callable, Mapping, Sequence
78
+ from dataclasses import dataclass, replace
79
+ from datetime import datetime, timedelta
80
+ from pathlib import Path
81
+ from typing import TypedDict
82
+
83
+ from agent_action_capsule import json_digest
84
+ from agent_action_capsule.canonical import FloatInDigestError, UnsafeIntegerError
85
+ from cll.ledger import LedgerStore
86
+ from cll.ledger.api import LedgerAPI
87
+
88
+ from ..folds.definition import FoldDefinition
89
+ from ..folds.duration import parse_duration_seconds
90
+ from ..guards import ALLOW, ESCALATE, Action, GuardDecision, GuardEngine, LocalSigner
91
+ from ..guards.action import disclosed_classes_of, handover_of, listing_classes_of, repeat_disclosures_of
92
+ from ..guards.checks.caps import EXECUTED_DECISION, SPEND_UNREADABLE, counts_executed_acts
93
+ from ..guards.checks.commitment import text_commitment
94
+ from ..guards.checks.promise_never import ATTRIBUTED_TO_USER, WORDS_NOT_IN_BUNDLE
95
+ from ..guards.history_state import CHECK_FP_ALGS, RULES_HISTORY_DAYS, well_formed_counterparty
96
+ from ..guards.statements import StatementRecord, claim_of, make_statement_record
97
+ from ..guards.wickets.definition import WicketDefinition
98
+ from ..packs.install import engine_ask_sets
99
+ from ..packs.schema import PackDefinition
100
+
101
+ __all__ = [
102
+ "MONEY_IN",
103
+ "ExecutedActs",
104
+ "bound_agent_input",
105
+ "SourcedDecision",
106
+ "ReplayResult",
107
+ "SaleCheck",
108
+ "SaleHistory",
109
+ "load_records",
110
+ "load_disclosed",
111
+ "load_representing_openings",
112
+ "representing_openings",
113
+ "load_withheld",
114
+ "filter_since",
115
+ "action_for_record",
116
+ "action_for_check_input",
117
+ "action_for_history_entry",
118
+ "deal_claim_statements",
119
+ "names_two_deals",
120
+ "replay",
121
+ "statement_for_history_entry",
122
+ "statement_record",
123
+ ]
124
+
125
+ _TRANSFER_NOTE_RE = re.compile(r"amount_eur:\s*(\d+).*?target_iban:\s*([A-Z0-9]+)")
126
+
127
+
128
+ def load_records(paths: Sequence[str | Path]) -> list[dict]:
129
+ """Load every record from one or more sources, in the given order.
130
+
131
+ Each source is either a plain JSONL ledger file (read in file-internal
132
+ line order), a ``LedgerStore`` directory (read via ``scan()``, its own
133
+ append order) -- the CLI's real-deployment path, since an operator's
134
+ actual local ledger is a store directory, not a loose JSONL file -- or a
135
+ capsule bundle (one JSON object with ``records`` and ``disclosures``, as
136
+ ``capsulectl bundle --deal`` writes), read in its record order.
137
+ """
138
+ records: list[dict] = []
139
+ for path in paths:
140
+ p = Path(path)
141
+ bundle = _bundle(p)
142
+ if bundle is not None:
143
+ records.extend(bundle["records"])
144
+ elif p.is_dir():
145
+ store = LedgerStore(p)
146
+ try:
147
+ for rec in store.scan():
148
+ records.append(rec.capsule)
149
+ finally:
150
+ store.close()
151
+ else:
152
+ with p.open(encoding="utf-8") as fh:
153
+ for line in fh:
154
+ line = line.strip()
155
+ if line:
156
+ records.append(json.loads(line))
157
+ return records
158
+
159
+
160
+ def _bundle(path: Path) -> dict | None:
161
+ """``path``'s capsule bundle, or ``None`` when it is not one."""
162
+ if path.is_dir():
163
+ return None
164
+ try:
165
+ value = json.loads(path.read_text(encoding="utf-8"))
166
+ except ValueError:
167
+ return None
168
+ if isinstance(value, dict) and isinstance(value.get("records"), list) and isinstance(value.get("disclosures"), dict):
169
+ return value
170
+ return None
171
+
172
+
173
+ def load_disclosed(paths: Sequence[str | Path]) -> dict[str, dict]:
174
+ """The records a capsule bundle discloses beside its capsules, by
175
+ ``capsule_id``: each one's ``agent_input``. Sources that are not bundles
176
+ disclose nothing."""
177
+ disclosed: dict[str, dict] = {}
178
+ for path in paths:
179
+ bundle = _bundle(Path(path))
180
+ if bundle is None:
181
+ continue
182
+ for capsule_id, members in bundle["disclosures"].items():
183
+ agent_input = members.get("agent_input") if isinstance(members, dict) else None
184
+ if isinstance(agent_input, dict):
185
+ disclosed[capsule_id] = agent_input
186
+ return disclosed
187
+
188
+
189
+ # Where capsulectl puts a deal's report in a bundle (capsule-cli
190
+ # internal/cli/deal_sealed_report.go, DealSealedReport): inline as the
191
+ # x-deal-v0 extension itself, standalone in the x-deal-report/v0 extension,
192
+ # or in the log as the record the extension's sealed_report names.
193
+ _DEAL_EXTENSION = "x-deal-v0"
194
+ _REPORT_EXTENSION = "x-deal-report/v0"
195
+ _REPORT_POINTER = "sealed_report"
196
+
197
+
198
+ def _bundle_reports(bundle: dict) -> list[dict]:
199
+ """Every deal report ``bundle`` carries, in any of capsulectl's three
200
+ forms; nothing about them is trusted here (``representing_openings``)."""
201
+ extensions = bundle.get("extensions") if isinstance(bundle.get("extensions"), dict) else {}
202
+ reports: list[object] = []
203
+ deal = extensions.get(_DEAL_EXTENSION)
204
+ if isinstance(deal, dict):
205
+ reports.append(deal)
206
+ pointer = deal.get(_REPORT_POINTER)
207
+ disclosures = bundle.get("disclosures") if isinstance(bundle.get("disclosures"), dict) else {}
208
+ shown = disclosures.get(pointer) if isinstance(pointer, str) else None
209
+ agent_input = shown.get("agent_input") if isinstance(shown, dict) else None
210
+ if isinstance(agent_input, dict):
211
+ reports.append(agent_input.get("report"))
212
+ standalone = extensions.get(_REPORT_EXTENSION)
213
+ agent_input = standalone.get("agent_input") if isinstance(standalone, dict) else None
214
+ if isinstance(agent_input, dict):
215
+ reports.append(agent_input.get("report"))
216
+ return [r for r in reports if isinstance(r, dict)]
217
+
218
+
219
+ def representing_openings(bundle: object) -> dict[str, dict]:
220
+ """The openings of the agent's statements a bundle's deal report carries
221
+ (its ``representations``, each ``{nonce, text}``), by the commitment each
222
+ recomputes to (``commitment.text_commitment``). An opening proves itself:
223
+ the words that recompute a represent check's sealed ``text_commitment``
224
+ are the words that check sealed, so the report's own binding is not
225
+ needed to read them (capsulectl seals a claim under its check's nonce,
226
+ so a claim's opening is its check's). An entry whose nonce is not 64
227
+ lowercase hex or whose text is not a string, or that has no JCS bytes,
228
+ is skipped."""
229
+ openings: dict[str, dict] = {}
230
+ if not isinstance(bundle, dict):
231
+ return openings
232
+ for report in _bundle_reports(bundle):
233
+ said = report.get("representations")
234
+ for entry in said if isinstance(said, list) else []:
235
+ nonce, text = (entry.get("nonce"), entry.get("text")) if isinstance(entry, dict) else (None, None)
236
+ if not isinstance(nonce, str) or not _HEX64.fullmatch(nonce) or not isinstance(text, str):
237
+ continue
238
+ try:
239
+ openings.setdefault(text_commitment(nonce, text), {"nonce": nonce, "text": text})
240
+ except UnicodeEncodeError:
241
+ continue
242
+ return openings
243
+
244
+
245
+ def load_representing_openings(paths: Sequence[str | Path]) -> dict[str, dict]:
246
+ """``representing_openings`` of every capsule bundle among ``paths``."""
247
+ openings: dict[str, dict] = {}
248
+ for path in paths:
249
+ for commitment, opening in representing_openings(_bundle(Path(path))).items():
250
+ openings.setdefault(commitment, opening)
251
+ return openings
252
+
253
+
254
+ def load_withheld(paths: Sequence[str | Path]) -> frozenset[str]:
255
+ """The ``capsule_id`` of every record a capsule bundle among ``paths``
256
+ carries without disclosing its ``agent_input``, and no other source
257
+ discloses. Plain ledgers and store directories withhold nothing."""
258
+ carried: set[str] = set()
259
+ for path in paths:
260
+ bundle = _bundle(Path(path))
261
+ if bundle is not None:
262
+ carried.update(r["capsule_id"] for r in bundle["records"] if isinstance(r.get("capsule_id"), str))
263
+ return frozenset(carried - load_disclosed(paths).keys())
264
+
265
+
266
+ def _parse_ts(ts: str) -> datetime:
267
+ text = ts[:-1] + "+00:00" if ts.endswith("Z") else ts
268
+ return datetime.fromisoformat(text)
269
+
270
+
271
+ def filter_since(records: list[dict], since: str | None) -> list[dict]:
272
+ """Keep only records within ``since`` (e.g. ``"7d"``) of the *latest*
273
+ timestamp in the set -- anchored to real ledger data, never the system
274
+ wall clock (same determinism principle as the fold engine's own rolling
275
+ windows: ``folds/engine.py`` refuses to invent an anchor from
276
+ ``datetime.now()``)."""
277
+ if since is None:
278
+ return list(records)
279
+ seconds = parse_duration_seconds(since)
280
+ timestamps = [r["timestamp"] for r in records if r.get("timestamp")]
281
+ if not timestamps:
282
+ return list(records)
283
+ anchor = max(_parse_ts(t) for t in timestamps)
284
+ cutoff = anchor - timedelta(seconds=seconds)
285
+ return [r for r in records if r.get("timestamp") and _parse_ts(r["timestamp"]) >= cutoff]
286
+
287
+
288
+ def _bridge_transfer_funds(record: dict) -> Action | None:
289
+ action_id = record.get("action_id") or ""
290
+ verb = action_id.split("/", 1)[0] if action_id else ""
291
+ if verb != "transfer_funds":
292
+ return None
293
+ note = ((record.get("model_attestation") or {}).get("compute_attestation") or {}).get("note", "")
294
+ match = _TRANSFER_NOTE_RE.search(note)
295
+ if not match:
296
+ return None
297
+ amount_eur, target_iban = match.groups()
298
+ return Action(
299
+ verb=verb,
300
+ operator=record.get("operator", ""),
301
+ developer=record.get("developer", ""),
302
+ action_class="money.transfer",
303
+ amount_minor=int(amount_eur) * 100,
304
+ currency="EUR",
305
+ target=target_iban,
306
+ timestamp=record.get("timestamp"),
307
+ action_id=action_id or None,
308
+ )
309
+
310
+
311
+ def _bound(record: dict, disclosed: dict) -> bool:
312
+ """Whether ``disclosed`` is the record ``record``'s capsule sealed."""
313
+ attestation = (record.get("model_attestation") or {}).get("compute_attestation") or {}
314
+ bound = attestation.get("agent_input_digest")
315
+ return isinstance(bound, str) and json_digest(disclosed) == bound
316
+
317
+
318
+ def _checked_body(disclosed: dict, *, history: bool = False) -> tuple[dict, object] | None:
319
+ """The body of a deal check (an ``x-deal-v0`` check record, or a typed
320
+ ``proposed-action/v0``) and the counterparty block it seals, or ``None``
321
+ for any other record. An ``x-deal-v0`` check seals the counterparty in its
322
+ block; capsulectl moves it into a typed record's body, the same
323
+ fingerprints under ``fp_alg`` ``hmac-sha256-chain-key``. With ``history``,
324
+ an act carried out is read the same way, in either shape a check input's
325
+ history states one: a typed act record (``action-record/v0``), or an
326
+ ``x-deal-v0`` action step, which seals the counterparty in its block as a
327
+ check does. An action step whose body states an act taken without a check
328
+ (``body.unchecked``) is not read: it was never authorized."""
329
+ block = disclosed.get("x-deal-v0")
330
+ body = disclosed.get("body")
331
+ record_type = block.get("record_type") if isinstance(block, dict) else None
332
+ if record_type == "check" or (history and _is_action_step(disclosed) and not _taken_unchecked(body)):
333
+ counterparty = block.get("counterparty")
334
+ elif disclosed.get("type") == "proposed-action/v0" or (history and disclosed.get("type") == _ACT_RECORD_TYPE):
335
+ counterparty = body.get("counterparty") if isinstance(body, dict) else None
336
+ else:
337
+ return None
338
+ return (body, counterparty) if isinstance(body, dict) else None
339
+
340
+
341
+ # The sealed rule input on an offer that answers no proposal (capsule-cli
342
+ # deal_proposal.go), and the ref an offer names the message it answers by.
343
+ _NO_PROPOSAL = "no_proposal"
344
+ _SOURCE = "source"
345
+
346
+
347
+ def _is_check(disclosed: dict) -> bool:
348
+ """Whether ``disclosed`` is a deal check: an ``x-deal-v0`` check record
349
+ or a typed ``proposed-action/v0``."""
350
+ block = disclosed.get("x-deal-v0")
351
+ if isinstance(block, dict):
352
+ return block.get("record_type") == "check"
353
+ return disclosed.get("type") == _PROPOSED_TYPE
354
+
355
+
356
+ def _refs(disclosed: dict) -> list:
357
+ """A deal record's refs: an ``x-deal-v0`` record's block refs, else a
358
+ typed record's own; ``[]`` when it has none in a list."""
359
+ block = disclosed.get("x-deal-v0")
360
+ refs = block.get("refs") if isinstance(block, dict) else disclosed.get("refs")
361
+ return refs if isinstance(refs, list) else []
362
+
363
+
364
+ def _names_source(disclosed: dict) -> bool:
365
+ """Whether a deal record names any ``source`` ref, in whatever shape:
366
+ a check that does names a proposal it answers, resolved or not."""
367
+ return any(isinstance(r, dict) and r.get("rel") == _SOURCE for r in _refs(disclosed))
368
+
369
+
370
+ def _no_proposal(disclosed: dict, body: dict) -> bool | None:
371
+ """``True`` when ``disclosed`` is a check whose body seals
372
+ ``no_proposal: true`` and which names no ``source`` ref; else ``None``. A
373
+ check naming both says it answers a message, so it is decided on that
374
+ message's age (``_accepted_offer_at``), or fails as missing one: never
375
+ as answering no proposal."""
376
+ if _is_check(disclosed) and body.get(_NO_PROPOSAL) is True and not _names_source(disclosed):
377
+ return True
378
+ return None
379
+
380
+
381
+ def _is_action_step(disclosed: dict) -> bool:
382
+ """Whether ``disclosed`` is an ``x-deal-v0`` action step: an act carried
383
+ out, as a deal seals it."""
384
+ block = disclosed.get("x-deal-v0")
385
+ return isinstance(block, dict) and block.get("record_type") == _ACTION_STEP
386
+
387
+
388
+ def _taken_unchecked(body: object) -> bool:
389
+ """Whether a deal record's ``body`` states an act taken without a check
390
+ or without authority (``_taken``)."""
391
+ return isinstance(body, dict) and (_UNCHECKED in body or _ATTEMPTED in body)
392
+
393
+
394
+ def _minor(value: object) -> int | None:
395
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
396
+
397
+
398
+ def _sealed_counterparty(block: object) -> tuple[dict[str, str] | None, str | None]:
399
+ """The fingerprints (kind -> hex) in a sealed counterparty block
400
+ (``_checked_body``) and their ``fp_alg``, or ``(None, None)`` when it
401
+ holds none usable."""
402
+ if not isinstance(block, dict):
403
+ return None, None
404
+ ids, fp_alg = block.get("ids"), block.get("fp_alg")
405
+ if not isinstance(ids, dict) or not isinstance(fp_alg, str) or not fp_alg:
406
+ return None, None
407
+ usable = {k: v for k, v in ids.items() if isinstance(k, str) and isinstance(v, str) and v}
408
+ return (usable, fp_alg) if usable else (None, None)
409
+
410
+
411
+ _HEX64 = re.compile(r"^[0-9a-f]{64}$")
412
+
413
+
414
+ def _text(value: object) -> str | None:
415
+ return value if isinstance(value, str) and value else None
416
+
417
+
418
+ # A represent check (capsule-cli deal_representations.go), and the class the
419
+ # bridge gives one whose sealed class is not a non-empty string: no
420
+ # representation class, so promise_requires_approval and promise_never both
421
+ # fail it as not recognised. A closed token, never a stated value.
422
+ _REPRESENT = "represent"
423
+ MALFORMED_REPRESENTATION_CLASS = "malformed_representing"
424
+
425
+
426
+ def _attributed_to(value: object) -> str | None:
427
+ """A statement's attribution in its closed set (``user``), or ``None``:
428
+ any other value is read as the agent's own statement, which s10 refuses,
429
+ and is never sealed."""
430
+ return ATTRIBUTED_TO_USER if value == ATTRIBUTED_TO_USER else None
431
+
432
+
433
+ # The commitment the bridge gives a represent check whose sealed
434
+ # ``text_commitment`` is present but out of shape: not 64 lowercase hex, so
435
+ # ``promise_never`` fails it, never reading it as absent.
436
+ MALFORMED_TEXT_COMMITMENT = "malformed_text_commitment"
437
+
438
+
439
+ def _text_commitment(representing: dict) -> str | None:
440
+ """A represent check's sealed ``text_commitment``: the digest when it is
441
+ 64 lowercase hex, ``None`` when it is absent, and
442
+ ``MALFORMED_TEXT_COMMITMENT`` otherwise."""
443
+ if "text_commitment" not in representing:
444
+ return None
445
+ return _hex64(representing["text_commitment"]) or MALFORMED_TEXT_COMMITMENT
446
+
447
+
448
+ def _hex64(value: object) -> str | None:
449
+ return value if isinstance(value, str) and _HEX64.fullmatch(value) else None
450
+
451
+
452
+ def _flag(value: object) -> bool | None:
453
+ return value if isinstance(value, bool) else None
454
+
455
+
456
+ def _handover(body: dict) -> str | None:
457
+ """How the item reaches the buyer, as the check's ``terms.handover``
458
+ states it: carried only as a member of ``HANDOVERS`` (``handover_of``), and dropped
459
+ otherwise (a rail allowed only in person then fails, as with none)."""
460
+ terms = body.get("terms")
461
+ return handover_of(terms.get("handover")) if isinstance(terms, dict) else None
462
+
463
+
464
+ def _payee_target(counterparty_ids: dict[str, str] | None, fp_alg: str | None) -> str | None:
465
+ """The payee's sealed fingerprint as an opaque target,
466
+ ``payee-fp:<fp_alg>:<hex>``. Prefixed so it never compares equal to a
467
+ clear reference or to a fingerprint made by another algorithm; ``None``
468
+ when the record seals no payee fingerprint."""
469
+ payee = (counterparty_ids or {}).get("payee")
470
+ return f"payee-fp:{fp_alg}:{payee}" if payee and fp_alg else None
471
+
472
+
473
+ # The profile-scoped payee block's name, in the checker input and on the
474
+ # companion record, and the fp_alg it must carry.
475
+ _PROFILE_INPUT = "counterparty_profile"
476
+ _PROFILE_FP_ALG = "hmac-sha256-profile-key"
477
+ # The sale's item reference: top level in the checker input for the checked
478
+ # record, and on each history entry for that act (AMENDMENT 6a).
479
+ _ITEM_INPUT = "item_ref"
480
+ # The time of the accepted offer a seller's commit rests on, on the record
481
+ # entry beside its capsule (AMENDMENT 7): the offer record's sealed ``at``.
482
+ _PROPOSAL_INPUT = "proposal_at"
483
+ # Who a history act was with, on each history entry beside its capsule: the
484
+ # counterparty block the act's check sealed, exactly as sealed, and the
485
+ # fp_algs a check seals it under (an x-deal-v0 check's, a typed check's).
486
+ _COUNTERPARTY_INPUT = "counterparty"
487
+ # The deal's earlier claims the user's agent made, on the checked record entry
488
+ # beside its capsule (AMENDMENT 9): each the sealed claim record's own values.
489
+ _CLAIMS_INPUT = "deal_claims"
490
+
491
+
492
+ def _profile_target(block: object) -> str | None:
493
+ """The payee keyed per profile as a target, from a ``counterparty_profile``
494
+ block in its agreed shape, or ``None`` for anything else."""
495
+ if not isinstance(block, dict) or block.get("fp_alg") != _PROFILE_FP_ALG:
496
+ return None
497
+ ids = block.get("ids")
498
+ payee = ids.get("payee") if isinstance(ids, dict) else None
499
+ if not isinstance(payee, str) or not _HEX64.fullmatch(payee):
500
+ return None
501
+ return _payee_target({"payee": payee}, _PROFILE_FP_ALG)
502
+
503
+
504
+ def _check_counterparty(block: object) -> bool:
505
+ """Whether ``block`` is a check's counterparty block in its agreed shape,
506
+ ``{fp_alg: <a check's fp_alg>, ids: {<kind>: <64 lowercase hex>}}``, so
507
+ ``_sealed_counterparty`` may read it as the check's own.
508
+
509
+ Looser than ``history_state.well_formed_counterparty`` on purpose: it
510
+ decides only whether the act gets its check's target, as it has since
511
+ the act counterparty was added, so it does not check the kinds or refuse
512
+ an empty block, and tightening it would move other checks' decisions.
513
+ ``release_on_acceptance`` 1.2.0 reads the strict rule through
514
+ ``Action.counterparty_malformed``."""
515
+ if not isinstance(block, dict) or block.get("fp_alg") not in CHECK_FP_ALGS:
516
+ return False
517
+ ids = block.get("ids")
518
+ return isinstance(ids, dict) and all(isinstance(v, str) and _HEX64.fullmatch(v) for v in ids.values())
519
+
520
+
521
+ def _typed_ref_digest(value: object) -> str | None:
522
+ """The digest of a typed record reference ``{type, digest_alg, digest}``
523
+ when it is a SHA-256 one, or ``None``."""
524
+ if not isinstance(value, dict) or not _text(value.get("type")) or value.get("digest_alg") != "SHA-256":
525
+ return None
526
+ digest = value.get("digest")
527
+ return digest if isinstance(digest, str) and _HEX64.fullmatch(digest) else None
528
+
529
+
530
+ # The typed record a check input's history carries for each act carried out
531
+ # (capsule-cli internal/cli/rules_checker.go, rulesHistory), and the
532
+ # ``authority_basis`` entry type that names the task authority it was taken
533
+ # under (action_records.go): an act record has no ``task_authority_ref``.
534
+ _ACT_RECORD_TYPE = "action-record/v0"
535
+ _TASK_AUTHORITY = "task-authority/v0"
536
+ _TASK_AUTHORITY_BASIS = "task_authority"
537
+
538
+
539
+ def _basis_task_authority(body: dict) -> str | None:
540
+ """The digest of the task authority an act record's ``authority_basis``
541
+ names: the ``ref`` of its one entry of type ``task_authority``. ``None``
542
+ when it has none, more than one, or a ref that is not a SHA-256 typed
543
+ reference."""
544
+ basis = body.get("authority_basis")
545
+ entries = [e for e in basis if isinstance(e, dict) and e.get("type") == _TASK_AUTHORITY_BASIS] if isinstance(
546
+ basis, list) else []
547
+ return _typed_ref_digest(entries[0].get("ref")) if len(entries) == 1 else None
548
+
549
+
550
+ # The ``direction`` a deal check states when the money moves to the user: a
551
+ # refund, or a partial cancel. Such an act is never spend: the bridge carries
552
+ # its spend as ``0`` whatever ``spend_minor`` says, so it adds nothing to the
553
+ # rolling total and cannot raise it past a cap. So an act stated in this
554
+ # direction is never a reason the spend window cannot be computed, decided or not.
555
+ MONEY_IN = "in"
556
+
557
+
558
+ # The body fields that state the amount a money-in record returns, first
559
+ # present wins: the producer's explicit ``returned_minor``, then a refund's
560
+ # ``amount_minor``, then a partial cancel's ``cancelled_amount_minor``.
561
+ _RETURNED_AMOUNT_FIELDS = ("returned_minor", "amount_minor", "cancelled_amount_minor")
562
+
563
+
564
+ def _returned_minor(body: dict) -> int | None:
565
+ """The amount a money-in check returns (``_RETURNED_AMOUNT_FIELDS``)."""
566
+ return next((v for v in (_minor(body.get(f)) for f in _RETURNED_AMOUNT_FIELDS) if v is not None), None)
567
+
568
+
569
+ # Where a deal record names its deal: an ``x-deal-v0`` record in its block's
570
+ # ``deal_id``, a typed record in its top-level ``chain_id``, which capsulectl
571
+ # sets to exactly that value. Read only through ``_deal_id``.
572
+ _DEAL_ID_FIELDS = ("x-deal-v0.deal_id", "chain_id")
573
+
574
+
575
+ @dataclass(frozen=True)
576
+ class _DealId:
577
+ """The deal a record names, or ``None``; ``conflict`` when it names two."""
578
+
579
+ value: str | None
580
+ conflict: bool = False
581
+
582
+
583
+ def _deal_id(shown: dict) -> _DealId:
584
+ """The deal ``shown`` is in: ``x-deal-v0.deal_id`` when the record states
585
+ one, else its typed ``chain_id``. A record stating both, and not the same
586
+ value, names no deal and is a conflict: neither is picked. A stated value
587
+ that is not a non-empty string names no deal."""
588
+ block = shown.get("x-deal-v0")
589
+ stated = isinstance(block, dict) and "deal_id" in block
590
+ chained = "chain_id" in shown
591
+ if stated and chained and block["deal_id"] != shown["chain_id"]:
592
+ return _DealId(value=None, conflict=True)
593
+ return _DealId(value=_text(block["deal_id"]) if stated else _text(shown.get("chain_id")))
594
+
595
+
596
+ def names_two_deals(shown: dict) -> bool:
597
+ """Whether the record ``shown`` names two deals (``_deal_id``), so it is
598
+ in none."""
599
+ return _deal_id(shown).conflict
600
+
601
+
602
+ def _bridge_deal_check(
603
+ record: dict,
604
+ disclosed: dict | None,
605
+ counterparty_profile: object = None,
606
+ *,
607
+ history: bool = False,
608
+ act_counterparty: object = None,
609
+ ) -> Action | None:
610
+ """The proposed action a capsulectl deal check states, from its own
611
+ sealed record: the class it names, and the amount a spend cap evaluates,
612
+ which is ``spend_minor`` only: never ``amount_minor`` (on a cancel it is
613
+ a refund) or ``cancelled_amount_minor``, and ``0`` for a record that says
614
+ the money moved in. So a cancel is never spend. The amount the body
615
+ states (``amount_minor``) is carried apart, as ``stated_amount_minor``, for
616
+ ``price_floor``: on a sale it is the price, while the spend is ``0``.
617
+ ``spend_authorized_minor``,
618
+ the authorised maximum sealed beside it, is carried for a per-action cap
619
+ and dropped when the money moved in. When it moved in, the amount
620
+ returned (``_returned_minor``) and the SHA-256 digest of the reversed
621
+ act's record (the body's typed ``reverses_ref``) are carried for
622
+ ``dedupe`` beside the spend of ``0``, and never otherwise.
623
+ Only a check is an action here: the step that acts on it is the same
624
+ payment, and counting both would count it twice.
625
+ The rail and ``refundable`` come from the body's ``recourse`` block, where
626
+ the producer writes them (a top-level ``rail`` is read when there is no
627
+ recourse rail). The handover comes from the body's ``terms`` block
628
+ (``_handover``), as a seller's commit or sale states it. The target is the payee's sealed fingerprint
629
+ (``_payee_target``) from the counterparty block the check seals
630
+ (``_checked_body``), typed or not, so a check sealing none has no target;
631
+ or ``counterparty_profile``'s when it is given in
632
+ its agreed shape (``_profile_target``); given in any other, it is named
633
+ in ``ignored_inputs``. The remaining body fields are carried only in the
634
+ shape the action takes: a string, a boolean, an integer, or the SHA-256
635
+ digest of a typed reference, and dropped otherwise. A share's
636
+ ``disclosing``, the classes of data it gives, is carried as
637
+ ``disclosed_classes`` only as a non-empty list of known class names
638
+ (``DISCLOSED_CLASSES``); with any other member it is dropped whole.
639
+ ``repeat_disclosures`` is carried the same way, possibly empty, and
640
+ ``recipient_told_before`` only as a boolean (capsule-engine#188); neither
641
+ is sealed on the decision. A sale thread's ``listing_classes`` (the
642
+ classes every live listing stated when the check was made) is carried as
643
+ one only as a list of class names, and dropped whole otherwise; it is read
644
+ here and never sealed on the decision.
645
+ A check's sealed ``no_proposal`` is carried only when it is ``true`` and
646
+ the check names no ``source`` ref (``_no_proposal``); it is never read
647
+ off an act a history entry records.
648
+ A represent check's ``representing`` gives the class of the statement
649
+ (``representation_class``), whose claim it is (``attributed_to``, as
650
+ ``statement_attributed_to``, its closed set only) and the commitment to its words
651
+ (``text_commitment``, as ``representation_text_commitment``: the digest
652
+ when it is 64 lowercase hex, ``MALFORMED_TEXT_COMMITMENT`` when it is out
653
+ of shape, ``None`` when absent), so ``promise_requires_approval``
654
+ and ``promise_never`` decide it (capsule-engine#180).
655
+ ``taxonomy_version`` makes it an act stated against a pinned taxonomy, so
656
+ ``dedupe`` keys it on the act, never on this record's type or id. The
657
+ deal is the one the record names (``_deal_id``), never the ``action_id``
658
+ prefix. A record naming two deals carries none, and names both fields in
659
+ ``deal_id_conflict``, so the engine refuses whatever would be allowed.
660
+ With ``history``, a typed act record is bridged too (``_checked_body``),
661
+ and its task authority is the one its ``authority_basis`` names
662
+ (``_basis_task_authority``). An act record seals no counterparty, so
663
+ ``act_counterparty``, the block its check sealed, given beside it, is
664
+ read in its place, as the check's own (``_check_counterparty``): the act
665
+ gets the target its check got, keyed per deal. Given in any other shape,
666
+ it is named in ``ignored_inputs`` and the act has no target from it."""
667
+ if disclosed is None or not _bound(record, disclosed):
668
+ return None
669
+ checked = _checked_body(disclosed, history=history)
670
+ if checked is None:
671
+ return None
672
+ body, sealed_counterparty = checked
673
+ if not body.get("action_class") or not body.get("taxonomy_version"):
674
+ return None
675
+ spend = body.get("spend_minor")
676
+ authorized = body.get("spend_authorized_minor")
677
+ returned = reverses = None
678
+ if body.get("direction") == MONEY_IN:
679
+ spend = 0
680
+ authorized = None
681
+ returned, reverses = _returned_minor(body), _typed_ref_digest(body.get("reverses_ref"))
682
+ if reverses is None and history and _is_action_step(disclosed):
683
+ # An action step names the act it reverses in its refs, not its body.
684
+ reverses = _only_ref(disclosed, "reverses")
685
+ deal = _deal_id(disclosed)
686
+ ignored: list[str] = []
687
+ if act_counterparty is not None and sealed_counterparty is None:
688
+ if _check_counterparty(act_counterparty):
689
+ sealed_counterparty = act_counterparty
690
+ else:
691
+ ignored.append(_COUNTERPARTY_INPUT)
692
+ counterparty_ids, fp_alg = _sealed_counterparty(sealed_counterparty)
693
+ malformed = sealed_counterparty is not None and not (
694
+ isinstance(sealed_counterparty, dict)
695
+ and well_formed_counterparty(sealed_counterparty.get("fp_alg"), sealed_counterparty.get("ids")))
696
+ target = _payee_target(counterparty_ids, fp_alg)
697
+ if counterparty_profile is not None:
698
+ profile_target = _profile_target(counterparty_profile)
699
+ if profile_target is None:
700
+ ignored.insert(0, _PROFILE_INPUT)
701
+ else:
702
+ target = profile_target
703
+ recourse = body.get("recourse") if isinstance(body.get("recourse"), dict) else {}
704
+ representing = body.get("representing") if isinstance(body.get("representing"), dict) else {}
705
+ stated_class = _text(representing.get("class"))
706
+ if stated_class is None and body.get("action") == _REPRESENT:
707
+ # A represent check whose class cannot be read (no representing, or
708
+ # one out of shape): a class no rule recognises, so s04 and s10 fail
709
+ # it rather than read it as n/a.
710
+ stated_class = MALFORMED_REPRESENTATION_CLASS
711
+ return Action(
712
+ verb=str(body.get("action") or "unknown"),
713
+ operator=record.get("operator", ""),
714
+ developer=record.get("developer", ""),
715
+ action_class=body["action_class"],
716
+ action_id=record.get("action_id") or None,
717
+ action_type=record.get("action_type", "decide"),
718
+ timestamp=record.get("timestamp"),
719
+ amount_minor=_minor(spend),
720
+ spend_authorized_minor=_minor(authorized),
721
+ stated_amount_minor=_minor(body.get("amount_minor")),
722
+ currency=body.get("currency"),
723
+ rail=_text(recourse.get("rail")) or _text(body.get("rail")),
724
+ target=target,
725
+ taxonomy_version=body["taxonomy_version"],
726
+ counterparty_ids=counterparty_ids,
727
+ counterparty_fp_alg=fp_alg,
728
+ refundable=_flag(recourse.get("refundable")),
729
+ handover=_handover(body),
730
+ recipient_role=_text(body.get("recipient_role")),
731
+ disclosed_classes=disclosed_classes_of(body.get("disclosing")),
732
+ repeat_disclosures=repeat_disclosures_of(body.get("repeat_disclosures")),
733
+ recipient_told_before=_flag(body.get("recipient_told_before")),
734
+ listing_classes=listing_classes_of(body.get("listing_classes")),
735
+ representation_class=stated_class,
736
+ statement_attributed_to=_attributed_to(representing.get("attributed_to")),
737
+ representation_text_commitment=_text_commitment(representing),
738
+ channel=_text(body.get("channel")),
739
+ first_contact_channel=_text(body.get("first_contact_channel")),
740
+ upfront_amount_minor=_minor(body.get("upfront_amount_minor")),
741
+ material_fields_changed=_minor(body.get("material_fields_changed")),
742
+ material_fields_basis=_text(body.get("material_fields_basis")),
743
+ offer_fields_changed=_minor(body.get("offer_fields_changed")),
744
+ offer_fields_basis=_text(body.get("offer_fields_basis")),
745
+ task_authority_ref=_basis_task_authority(body) if history else _typed_ref_digest(body.get("task_authority_ref")),
746
+ no_proposal=_no_proposal(disclosed, body),
747
+ returned_minor=returned,
748
+ reverses_ref=reverses,
749
+ deal_id=deal.value,
750
+ deal_id_conflict=_DEAL_ID_FIELDS if deal.conflict else (),
751
+ counterparty_malformed=malformed,
752
+ ignored_inputs=tuple(ignored),
753
+ )
754
+
755
+
756
+ # The ``x-deal-v0`` record type of a statement in a deal (capsule-cli
757
+ # internal/cli/deal_claims.go), and the ``source_kind`` of one the user's
758
+ # agent made to the counterparty.
759
+ _CLAIM = "claim"
760
+ _AGENT = "agent"
761
+
762
+
763
+ def statement_record(record: dict, disclosed: dict | None) -> StatementRecord | None:
764
+ """The record of the statement ``record`` makes to the counterparty
765
+ (``guards/statements.py``), or ``None`` when it makes none: it must be a
766
+ bound ``x-deal-v0`` ``claim`` whose body seals ``source_kind``
767
+ ``agent`` and a non-empty ``class``, in the one deal it names
768
+ (``_deal_id``; a record naming two names none). A disclosure record's
769
+ ``body.fields[].class`` is never read: that is the user's personal data
770
+ given out, not a statement about the deal. The same reader serves the
771
+ replay and a live check's history."""
772
+ if disclosed is None or not _bound(record, disclosed):
773
+ return None
774
+ block = disclosed.get("x-deal-v0")
775
+ body = disclosed.get("body")
776
+ if not isinstance(block, dict) or block.get("record_type") != _CLAIM or not isinstance(body, dict):
777
+ return None
778
+ sealed_class = _text(body.get("class"))
779
+ deal = _deal_id(disclosed).value
780
+ capsule_id = _text(record.get("capsule_id"))
781
+ if body.get("source_kind") != _AGENT or sealed_class is None or deal is None or capsule_id is None:
782
+ return None
783
+ # The claim's own sealed ``at``, never the capsule's ``timestamp``: the same
784
+ # instant (one clock read in capsulectl), but only ``at`` is the string a
785
+ # live check receives in ``deal_claims``, so the two never diverge.
786
+ return make_statement_record(
787
+ operator=str(record.get("operator", "")), timestamp=_text(block.get("at")),
788
+ sealed_class=sealed_class, source_kind=_AGENT, deal_id=deal, claim=capsule_id,
789
+ )
790
+
791
+
792
+ # A capsulectl deal report's sealed ``type``: what the user asked and what was
793
+ # done, for one audience. It states no act.
794
+ _REPORT_TYPE = "deal_report"
795
+
796
+
797
+ def _gets_no_decision(record: dict, disclosed: dict | None, withheld: frozenset[str]) -> bool:
798
+ """Whether the replay skips ``record`` before the engine sees it: a bound
799
+ deal record other than a check (``_states_no_act``), a bound deal report,
800
+ or a record its bundle carries but withholds (``load_withheld``), so
801
+ nothing it states can be read. No rule fires on any of them, the gate
802
+ included. They stay in the ledger and its completeness proof."""
803
+ if disclosed is None:
804
+ return record.get("capsule_id") in withheld
805
+ if _bound(record, disclosed) and disclosed.get("type") == _REPORT_TYPE:
806
+ return True
807
+ return _states_no_act(record, disclosed)
808
+
809
+
810
+ # The typed records (capsule-cli internal/cli/action_records.go) that state no
811
+ # act: the task authority, an evaluation, an approval (the user's, or a
812
+ # counterparty's acceptance), the action taken, which carries out the act its
813
+ # proposed action already stated, its outcome and a report. Only a
814
+ # ``proposed-action/v0`` is a check. A type not named here still gets a
815
+ # decision.
816
+ _TYPED_NO_ACT = frozenset({
817
+ "task-authority/v0", "action-evaluation/v0", "action-approval/v0", "action-record/v0", "action-outcome/v0",
818
+ "action-report/v0",
819
+ })
820
+
821
+
822
+ def _states_no_act(record: dict, disclosed: dict | None) -> bool:
823
+ """Whether ``record`` is a deal record other than a check, read from the
824
+ ``x-deal-v0.record_type`` its capsule sealed: a baseline, verdict,
825
+ approval or intent states no act, and the action step carries out the
826
+ act its check already stated. A typed record, which has no ``x-deal-v0``
827
+ block, is read from its sealed ``type`` (``_TYPED_NO_ACT``). Never read
828
+ from ``action_id`` or ``action_type``, and never from a record the
829
+ capsule does not bind."""
830
+ if disclosed is None or not _bound(record, disclosed):
831
+ return False
832
+ block = disclosed.get("x-deal-v0")
833
+ if not isinstance(block, dict):
834
+ return disclosed.get("type") in _TYPED_NO_ACT
835
+ record_type = block.get("record_type")
836
+ return isinstance(record_type, str) and record_type != "check"
837
+
838
+
839
+ def action_for_record(record: dict, disclosed: dict | None = None, *, counterparty_profile: object = None) -> Action:
840
+ """The action ``record`` states. ``disclosed`` is the record its capsule
841
+ sealed, when a bundle disclosed it (``load_disclosed``).
842
+ ``counterparty_profile`` is the payee keyed per profile for a deal check,
843
+ from the checker input or the check's companion (module docstring)."""
844
+ bridged = _bridge_deal_check(record, disclosed, counterparty_profile) or _bridge_transfer_funds(record)
845
+ if bridged is not None:
846
+ return bridged
847
+ if _states_no_act(record, disclosed):
848
+ return replace(Action.from_capsule(record), states_act=False)
849
+ return Action.from_capsule(record)
850
+
851
+
852
+ def action_for_check_input(entry: dict, *, item_ref: object = None) -> Action:
853
+ """The action an entry of an external-check-input/v0 envelope states, the
854
+ ``record`` entry or one of ``history``: the sealed capsule, its disclosed
855
+ ``agent_input``, and, beside them, what capsulectl computes and passes
856
+ and the capsule does not seal: ``counterparty_profile``, ``proposal_at``
857
+ (AMENDMENT 7) and, on a history entry, ``item_ref`` (AMENDMENT 6a).
858
+ ``item_ref`` is the envelope's top-level ``item_ref`` for the record
859
+ entry; when it is not passed, the entry's own is read. Either is set on
860
+ the action when it is 64 lowercase hex and named in ``ignored_inputs``
861
+ otherwise. ``proposal_at`` is set when it is a string (``offer_expiry``
862
+ reads and judges it) and named in ``ignored_inputs`` otherwise.
863
+ ``deal_claims`` is read by ``deal_claim_statements``, and named in
864
+ ``ignored_inputs`` when any of it is ignored. The
865
+ envelope's other top-level members, ``party_role`` among them (a checker
866
+ may read it to pick a pack), are not read here: an input carrying one is
867
+ decided as one without it. The act a history entry records is read by
868
+ ``action_for_history_entry``; here a history entry's typed act record
869
+ states no act."""
870
+ capsule, agent_input = _entry_parts(entry)
871
+ action = action_for_record(capsule, agent_input, counterparty_profile=entry.get(_PROFILE_INPUT))
872
+ action = _with_inputs(action, entry, item_ref)
873
+ if _CLAIMS_INPUT in entry and deal_claim_statements(entry).ignored:
874
+ action = replace(action, ignored_inputs=(*action.ignored_inputs, _CLAIMS_INPUT))
875
+ return action
876
+
877
+
878
+ @dataclass(frozen=True)
879
+ class DealClaims:
880
+ """The statements a checked record entry's ``deal_claims`` makes, and
881
+ whether any of it was ignored."""
882
+
883
+ statements: tuple[StatementRecord, ...]
884
+ ignored: bool
885
+
886
+
887
+ def deal_claim_statements(entry: dict) -> DealClaims:
888
+ """The statements the ``deal_claims`` beside a checked record entry make
889
+ (AMENDMENT 9), as ``statement_record`` writes them for the same claims in
890
+ a bundle: the claim's ``capsule_id``, its class (``delivery_promise`` read
891
+ as ``delivery_date``), ``source_kind`` ``agent``, the checked record's
892
+ deal and operator, and the claim's sealed ``at`` as its time. An entry
893
+ counts only in its agreed shape: ``capsule_id``, ``record_digest`` and
894
+ ``text_commitment`` 64 lowercase hex, a non-empty ``class``,
895
+ ``source_kind`` ``agent``, and ``at`` an RFC 3339 time with a zone at or
896
+ before the checked capsule's ``timestamp``. Any other entry, a
897
+ ``deal_claims`` that is not a list, or a checked record whose deal cannot
898
+ be read (unbound, or naming two deals) is ignored, and ``ignored`` says
899
+ so; nothing it holds is echoed. An empty list is no claims. The same
900
+ claim (one ``capsule_id``) given twice is one statement.
901
+
902
+ The entries are capsulectl's word, on the user's own device: an entry
903
+ names no deal of its own, so it is taken as the checked record's, and
904
+ its ``record_digest`` is checked for shape only, since a live check has
905
+ no claim record to recompute it from. A replay of the bundle reads the
906
+ sealed claims themselves. The time rule is "at or before", as for a
907
+ claim in the history: capsule times are whole seconds, and capsulectl
908
+ seals a claim and the step after it in one second."""
909
+ claims = entry.get(_CLAIMS_INPUT)
910
+ if claims is None:
911
+ return DealClaims(statements=(), ignored=False)
912
+ capsule, agent_input = _entry_parts(entry)
913
+ deal = _deal_id(agent_input).value if agent_input is not None and _bound(capsule, agent_input) else None
914
+ checked_at = _aware(capsule.get("timestamp"))
915
+ if not isinstance(claims, list) or deal is None or checked_at is None:
916
+ return DealClaims(statements=(), ignored=True)
917
+ statements: dict[str, StatementRecord] = {}
918
+ ignored = False
919
+ for claim in claims:
920
+ at = _aware(claim.get("at")) if isinstance(claim, dict) else None
921
+ if at is None or at > checked_at or not _claim_in_shape(claim):
922
+ ignored = True
923
+ continue
924
+ stated = make_statement_record(
925
+ operator=str(capsule.get("operator", "")), timestamp=claim["at"], sealed_class=claim["class"],
926
+ source_kind=_AGENT, deal_id=deal, claim=claim["capsule_id"],
927
+ )
928
+ statements.setdefault(claim_of(stated), stated)
929
+ return DealClaims(statements=tuple(statements.values()), ignored=ignored)
930
+
931
+
932
+ def _claim_in_shape(claim: dict) -> bool:
933
+ """Whether a ``deal_claims`` entry has its agreed members, each in shape
934
+ (``deal_claim_statements``); its ``at`` is checked by the caller."""
935
+ hexes = (claim.get(k) for k in ("capsule_id", "record_digest", "text_commitment"))
936
+ return (all(isinstance(v, str) and _HEX64.fullmatch(v) for v in hexes)
937
+ and _text(claim.get("class")) is not None and claim.get("source_kind") == _AGENT)
938
+
939
+
940
+ def _aware(value: object) -> datetime | None:
941
+ """``value`` as an aware time, when it is an RFC 3339 string with a zone."""
942
+ if not isinstance(value, str):
943
+ return None
944
+ try:
945
+ at = _parse_ts(value)
946
+ except ValueError:
947
+ # Not a time: the caller ignores what it dates.
948
+ return None
949
+ return at if at.tzinfo is not None else None
950
+
951
+
952
+ def action_for_history_entry(entry: dict) -> Action | None:
953
+ """The act a ``history`` entry of an external-check-input/v0 envelope
954
+ records: its sealed act, a typed act record (``action-record/v0``) or an
955
+ ``x-deal-v0`` action step, bridged as a deal check is
956
+ (``_bridge_deal_check``), with the task authority a typed record's
957
+ ``authority_basis`` names, the act an action step reverses read from its
958
+ ``refs`` (``rel`` ``reverses``), and the inputs beside it read as
959
+ ``action_for_check_input`` reads them (the entry's own ``item_ref``),
960
+ plus the entry's ``counterparty``: who the act was with, its target as
961
+ its check's (``_bridge_deal_check``). ``counterparty_profile`` still
962
+ replaces that target, as it does a check's. ``None`` when the entry is
963
+ not an act its capsule binds, or
964
+ names no class or taxonomy, or states an act taken without a check:
965
+ what it records cannot be read as a checked act."""
966
+ capsule, agent_input = _entry_parts(entry)
967
+ action = _bridge_deal_check(
968
+ capsule, agent_input, entry.get(_PROFILE_INPUT), history=True, act_counterparty=entry.get(_COUNTERPARTY_INPUT)
969
+ )
970
+ return _with_inputs(action, entry, None) if action is not None else None
971
+
972
+
973
+ def bound_agent_input(entry: dict) -> dict | None:
974
+ """An envelope entry's disclosed ``agent_input`` when its capsule binds
975
+ it, else ``None``."""
976
+ capsule, agent_input = _entry_parts(entry)
977
+ return agent_input if agent_input is not None and _bound(capsule, agent_input) else None
978
+
979
+
980
+ def statement_for_history_entry(entry: dict) -> StatementRecord | None:
981
+ """The statement a ``history`` entry of an external-check-input/v0
982
+ envelope records (``statement_record``), read from its sealed capsule and
983
+ disclosed ``agent_input``; ``None`` when it records none."""
984
+ capsule, agent_input = _entry_parts(entry)
985
+ return statement_record(capsule, agent_input)
986
+
987
+
988
+ def _entry_parts(entry: dict) -> tuple[dict, dict | None]:
989
+ """An envelope entry's capsule, without the members capsulectl passes
990
+ beside it, and its disclosed ``agent_input``."""
991
+ capsule = {
992
+ k: v
993
+ for k, v in entry.items()
994
+ if k not in ("agent_input", _PROFILE_INPUT, _PROPOSAL_INPUT, _ITEM_INPUT, _COUNTERPARTY_INPUT, _CLAIMS_INPUT)
995
+ }
996
+ agent_input = entry.get("agent_input")
997
+ return capsule, agent_input if isinstance(agent_input, dict) else None
998
+
999
+
1000
+ def _with_inputs(action: Action, entry: dict, item_ref: object) -> Action:
1001
+ """``action`` with the ``item_ref`` and ``proposal_at`` passed beside
1002
+ ``entry`` (``action_for_check_input``)."""
1003
+ ignored = list(action.ignored_inputs)
1004
+ if item_ref is None:
1005
+ item_ref = entry.get(_ITEM_INPUT)
1006
+ if item_ref is not None:
1007
+ if isinstance(item_ref, str) and _HEX64.fullmatch(item_ref):
1008
+ action = replace(action, item_ref=item_ref)
1009
+ else:
1010
+ ignored.append(_ITEM_INPUT)
1011
+ proposal_at = entry.get(_PROPOSAL_INPUT)
1012
+ if proposal_at is not None:
1013
+ if isinstance(proposal_at, str):
1014
+ action = replace(action, proposal_at=proposal_at)
1015
+ else:
1016
+ ignored.append(_PROPOSAL_INPUT)
1017
+ return replace(action, ignored_inputs=tuple(ignored))
1018
+
1019
+
1020
+ def _companion_profiles(records: list[dict], disclosed: dict[str, dict]) -> dict[str, object]:
1021
+ """Each check's ``counterparty_profile`` block, by the check's record
1022
+ digest, from the bound companions among ``records``: a
1023
+ ``counterparty_profile`` record with exactly one ref, ``rel`` ``about``,
1024
+ type ``deal-record``. A check two companions name gets their blocks as a
1025
+ list, which is no block, so it is ignored; a companion without one gets
1026
+ ``{}``, ignored the same way."""
1027
+ found: dict[str, list[object]] = {}
1028
+ for record in records:
1029
+ shown = disclosed.get(record.get("capsule_id", ""))
1030
+ if shown is None or not _bound(record, shown):
1031
+ continue
1032
+ block = shown.get("x-deal-v0")
1033
+ if not isinstance(block, dict) or block.get("record_type") != _PROFILE_INPUT:
1034
+ continue
1035
+ refs = block.get("refs")
1036
+ if not isinstance(refs, list) or len(refs) != 1 or not isinstance(refs[0], dict):
1037
+ continue
1038
+ (ref,) = refs
1039
+ digest = _typed_ref_digest(ref)
1040
+ if ref.get("rel") != "about" or ref.get("type") != "deal-record" or digest is None:
1041
+ continue
1042
+ found.setdefault(digest, []).append(block.get(_PROFILE_INPUT) or {})
1043
+ return {digest: blocks[0] if len(blocks) == 1 else blocks for digest, blocks in found.items()}
1044
+
1045
+
1046
+ def _deal_records(records: list[dict], disclosed: dict[str, dict]) -> dict[str, dict]:
1047
+ """Each bound ``x-deal-v0`` record among ``records``, by its digest."""
1048
+ found: dict[str, dict] = {}
1049
+ for record in records:
1050
+ shown = disclosed.get(record.get("capsule_id", ""))
1051
+ if shown is not None and isinstance(shown.get("x-deal-v0"), dict) and _bound(record, shown):
1052
+ found[json_digest(shown)] = shown
1053
+ return found
1054
+
1055
+
1056
+ # How a typed deal states a seller's offer, the buyer's acceptance of it and
1057
+ # the seller's commit (capsule-cli action_records.go).
1058
+ _PROPOSED_TYPE = "proposed-action/v0"
1059
+ _APPROVAL_TYPE = "action-approval/v0"
1060
+ _ACCEPTANCE_AUTHORITY = "counterparty_acceptance"
1061
+ _OFFER, _COMMIT = "offer", "commit"
1062
+
1063
+
1064
+ def _changes_details(block: dict) -> bool:
1065
+ """Whether the ``x-deal-v0`` record ``block`` changes the deal's details,
1066
+ so an earlier acceptance no longer stands: a change, or a message or
1067
+ evidence that names the counterparty (capsule-cli deal_rules.go,
1068
+ changesDetails)."""
1069
+ record_type = block.get("record_type")
1070
+ return record_type == "change" or (record_type in ("message", "evidence") and "counterparty" in block)
1071
+
1072
+
1073
+ def _accepted_offer_at(records: list[dict], disclosed: dict[str, dict]) -> dict[str, str]:
1074
+ """The proposal each bound typed commit rests on, as the ``at`` that
1075
+ offer's record seals, by the commit's record digest (the value capsulectl
1076
+ sends live as ``record.proposal_at``; its capsule's ``timestamp`` is the
1077
+ same instant, from one clock read, but is not read here):
1078
+ ``offer_expiry`` reads it as ``Action.proposal_at``. It is the deal's
1079
+ latest offer, when a bound acceptance (an ``action-approval/v0`` of
1080
+ authority ``counterparty_acceptance``) names that offer's record by
1081
+ ``proposed_action_ref`` and nothing after it changed the details
1082
+ (``_changes_details``), as capsule-cli's offerAccepted reads it. A later
1083
+ offer supersedes an earlier one and its acceptance. Every record is read
1084
+ within the deal it names (``_deal_id``), in ledger order. A commit with
1085
+ no such offer is left out, so its proposal's age stays unestablished.
1086
+
1087
+ A typed offer that names the counterparty's message it answers by its one
1088
+ ``source`` ref gets that message's time (the value capsulectl sends live
1089
+ on the offer): the message record's sealed ``sent_at``, else its sealed
1090
+ ``at``, only when ``sent_at`` is absent (``_message_time``). The message
1091
+ must be a bound ``x-deal-v0`` ``message`` from the
1092
+ counterparty, earlier in ledger order and in the same deal. A withheld,
1093
+ unbound or unresolved message, or more than one ``source`` ref, leaves
1094
+ the offer out, so its proposal's age stays unestablished."""
1095
+ latest: dict[str, tuple[str, str | None]] = {}
1096
+ accepted: dict[str, str | None] = {}
1097
+ messages: dict[str, tuple[str, str | None]] = {}
1098
+ found: dict[str, str] = {}
1099
+ for record in records:
1100
+ shown = disclosed.get(record.get("capsule_id", ""))
1101
+ if shown is None or not _bound(record, shown):
1102
+ continue
1103
+ deal = _deal_id(shown).value
1104
+ if deal is None:
1105
+ continue
1106
+ body = shown.get("body") if isinstance(shown.get("body"), dict) else {}
1107
+ block = shown.get("x-deal-v0")
1108
+ if isinstance(block, dict) and block.get("record_type") == _MESSAGE and body.get("from") == _COUNTERPARTY:
1109
+ messages[json_digest(shown)] = (deal, _message_time(block, body))
1110
+ if shown.get("type") == _PROPOSED_TYPE and body.get("action") == _OFFER:
1111
+ digest = json_digest(shown)
1112
+ latest[deal] = (digest, _text(shown.get("at")))
1113
+ accepted.pop(deal, None)
1114
+ answered = messages.get(_answered_message(shown) or "")
1115
+ if answered is not None and answered[0] == deal and answered[1] is not None:
1116
+ found[digest] = answered[1]
1117
+ elif shown.get("type") == _PROPOSED_TYPE and body.get("action") == _COMMIT:
1118
+ at = accepted.get(deal)
1119
+ if at is not None:
1120
+ found[json_digest(shown)] = at
1121
+ elif shown.get("type") == _APPROVAL_TYPE and body.get("authority") == _ACCEPTANCE_AUTHORITY:
1122
+ offer = latest.get(deal)
1123
+ if offer is not None and _typed_ref_digest(body.get("proposed_action_ref")) == offer[0]:
1124
+ accepted[deal] = offer[1]
1125
+ elif isinstance(block, dict) and _changes_details(block):
1126
+ accepted.pop(deal, None)
1127
+ return found
1128
+
1129
+
1130
+ # A deal's message record, and who sent it, as capsulectl seals it.
1131
+ _MESSAGE, _COUNTERPARTY = "message", "counterparty"
1132
+
1133
+
1134
+ # A sealed time as capsulectl writes it: RFC 3339 UTC, whole seconds.
1135
+ _SEALED_TIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
1136
+
1137
+
1138
+ def _sealed_time(value: object) -> datetime | None:
1139
+ if not isinstance(value, str) or not _SEALED_TIME.match(value):
1140
+ return None
1141
+ try:
1142
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
1143
+ except ValueError:
1144
+ # The shape matched but the date does not exist: no time.
1145
+ return None
1146
+
1147
+
1148
+ def _message_time(block: dict, body: dict) -> str | None:
1149
+ """When a counterparty's message was sent: its sealed ``sent_at`` when the
1150
+ member is present, else its sealed ``at`` (when it was noted). A present
1151
+ ``sent_at`` that is not a real UTC time, or that is later than the
1152
+ message's own ``at``, gives no time: it is never replaced by ``at``."""
1153
+ at = _sealed_time(block.get("at"))
1154
+ if "sent_at" not in body:
1155
+ return block["at"] if at is not None else None
1156
+ sent = _sealed_time(body["sent_at"])
1157
+ if sent is None or at is None or sent > at:
1158
+ return None
1159
+ return body["sent_at"]
1160
+
1161
+
1162
+ def _answered_message(shown: dict) -> str | None:
1163
+ """The digest a typed offer's one ``source`` ref names, when it has
1164
+ exactly one and it is a SHA-256 typed ref; else ``None``."""
1165
+ refs = shown.get("refs")
1166
+ found = [r for r in refs if isinstance(r, dict) and r.get("rel") == _SOURCE] if isinstance(refs, list) else []
1167
+ return _typed_ref_digest(found[0]) if len(found) == 1 else None
1168
+
1169
+
1170
+ def _record_type(shown: dict) -> str | None:
1171
+ record_type = shown["x-deal-v0"].get("record_type")
1172
+ return record_type if isinstance(record_type, str) else None
1173
+
1174
+
1175
+ def _only_ref(shown: dict, rel: str) -> str | None:
1176
+ """The digest of the record ``shown``'s one ``rel`` ref names, when it has
1177
+ exactly one and it is a SHA-256 ``deal-record`` ref; else ``None``."""
1178
+ refs = shown["x-deal-v0"].get("refs")
1179
+ found = [r for r in refs if isinstance(r, dict) and r.get("rel") == rel] if isinstance(refs, list) else []
1180
+ if len(found) != 1 or found[0].get("type") != "deal-record":
1181
+ return None
1182
+ return _typed_ref_digest(found[0])
1183
+
1184
+
1185
+ # The sealed approvers whose approval carries an act out: the user, or the
1186
+ # user's standing intent. Any other (``agent_card``: a click on a card the
1187
+ # agent composed) certifies no one's consent, so the act stays unseen. This is
1188
+ # the rule capsulectl's deal disposition applies live (capsule-cli
1189
+ # internal/cli/deal.go, dealDisposition), so replay never counts as seen a
1190
+ # counterparty the live path would not.
1191
+ _CONSENTING_APPROVERS = frozenset({"user", "standing_intent"})
1192
+
1193
+
1194
+ @dataclass(frozen=True)
1195
+ class _CarriedOut:
1196
+ """An executed action step's chain back to its check, as digests."""
1197
+
1198
+ check: str
1199
+ approval: str
1200
+ action: str
1201
+
1202
+
1203
+ def _carried_out(action_digest: str, deal: dict[str, dict]) -> _CarriedOut | None:
1204
+ """The chain by which the bound action step ``action_digest`` carried out
1205
+ a check, read from sealed records only: its one ``authorized_by`` ref
1206
+ names an approval whose body seals ``proceed: true`` and an approver in
1207
+ ``_CONSENTING_APPROVERS``, and that approval's one ``approves`` ref names
1208
+ the check, or the verdict whose one ``checks`` ref names it. Every record
1209
+ in the chain names the action's deal (``_deal_id``). ``None`` when any
1210
+ link is missing, unbound, declined, approved by no one who consents for
1211
+ the user, of another kind, or names two deals."""
1212
+ action = deal.get(action_digest)
1213
+ if action is None or _record_type(action) != "action":
1214
+ return None
1215
+ deal_id = _deal_id(action)
1216
+ if deal_id.conflict:
1217
+ return None
1218
+ approval_digest = _only_ref(action, "authorized_by")
1219
+ approval = deal.get(approval_digest) if approval_digest is not None else None
1220
+ if approval_digest is None or approval is None or _record_type(approval) != "approval":
1221
+ return None
1222
+ body = approval.get("body")
1223
+ if _deal_id(approval) != deal_id or not isinstance(body, dict) or body.get("proceed") is not True:
1224
+ return None
1225
+ if _text(body.get("approver")) not in _CONSENTING_APPROVERS:
1226
+ return None
1227
+ check_digest = _only_ref(approval, "approves")
1228
+ approved = deal.get(check_digest) if check_digest is not None else None
1229
+ if approved is not None and _record_type(approved) == "verdict":
1230
+ check_digest = _only_ref(approved, "checks")
1231
+ approved = deal.get(check_digest) if check_digest is not None else None
1232
+ if check_digest is None or approved is None or _record_type(approved) != "check":
1233
+ return None
1234
+ if _deal_id(approved) != deal_id:
1235
+ return None
1236
+ return _CarriedOut(check=check_digest, approval=approval_digest, action=action_digest)
1237
+
1238
+
1239
+ # The ``disposition.decision`` of the record a replay writes to its own view
1240
+ # for an act carried out (counterparty.seen_before/3.0.0 counts it). No guard
1241
+ # decision carries it, so no fold that reads accepted decisions counts it.
1242
+ _CARRIED_OUT = "carried_out"
1243
+
1244
+
1245
+ class _CarriedOutCitation(TypedDict):
1246
+ decision: str
1247
+ check: str
1248
+ approval: str
1249
+ action: str
1250
+
1251
+
1252
+ class _CarriedOutPayload(TypedDict):
1253
+ target: str | None
1254
+ carried_out: _CarriedOutCitation
1255
+
1256
+
1257
+ class _Disposition(TypedDict):
1258
+ decision: str
1259
+
1260
+
1261
+ class _CarriedOutBody(TypedDict):
1262
+ operator: str
1263
+ timestamp: str | None
1264
+ disposition: _Disposition
1265
+ asg_payload: _CarriedOutPayload
1266
+
1267
+
1268
+ class _CarriedOutRecord(_CarriedOutBody):
1269
+ capsule_id: str
1270
+
1271
+
1272
+ def _carried_out_record(sourced: SourcedDecision, chain: _CarriedOut, at: str | None) -> _CarriedOutRecord:
1273
+ """The record of ``sourced``'s act as carried out, citing its decision and
1274
+ the sealed chain by digest; its ``capsule_id`` is the digest of the rest.
1275
+ It is written to the replay's own view only."""
1276
+ citation = _CarriedOutCitation(
1277
+ decision=sourced.decision.capsule["capsule_id"], check=chain.check, approval=chain.approval, action=chain.action
1278
+ )
1279
+ body = _CarriedOutBody(
1280
+ operator=sourced.action.operator,
1281
+ timestamp=at,
1282
+ disposition=_Disposition(decision=_CARRIED_OUT),
1283
+ asg_payload=_CarriedOutPayload(target=sourced.action.target, carried_out=citation),
1284
+ )
1285
+ return _CarriedOutRecord(**body, capsule_id=json_digest(body))
1286
+
1287
+
1288
+ # How a deal seals an act it executed: an ``x-deal-v0`` action step, or an
1289
+ # outcome stating an act taken without a check (``body.unchecked``); a typed
1290
+ # deal seals them as an ``action-record/v0`` and an ``action-outcome/v0``
1291
+ # (``body.attempted``) (capsule-cli deal_profile.go, action_records.go). A
1292
+ # typed action record of a disclosure (``body.disclosed``) moves no money.
1293
+ _ACTION_STEP, _OUTCOME_STEP = "action", "outcome"
1294
+ _UNCHECKED, _ATTEMPTED, _DISCLOSED = "unchecked", "attempted", "disclosed"
1295
+ _ACCEPT = "accept"
1296
+
1297
+
1298
+ @dataclass(frozen=True)
1299
+ class _ExecutedAct:
1300
+ """An act a deal executed, as its record seals it: the spend it took
1301
+ (``_executed_spend``), ``None`` when that cannot be read, and its class."""
1302
+
1303
+ spend_minor: int | None
1304
+ action_class: str | None
1305
+
1306
+
1307
+ def _taken(shown: dict, *, every_record_is_an_act: bool) -> tuple[dict, bool] | None:
1308
+ """The body stating the act the bound record ``shown`` records as
1309
+ executed, and whether it was taken without a check; ``None`` for a
1310
+ record that states no executed act. A check input's history holds acts
1311
+ only (``every_record_is_an_act``), so any other record there is read as
1312
+ a checked act's body."""
1313
+ body = shown.get("body")
1314
+ if not isinstance(body, dict):
1315
+ return None
1316
+ block = shown.get("x-deal-v0")
1317
+ record_type = block.get("record_type") if isinstance(block, dict) else shown.get("type")
1318
+ if record_type in (_OUTCOME_STEP, "action-outcome/v0") or _UNCHECKED in body or _ATTEMPTED in body:
1319
+ taken = body.get(_UNCHECKED, body.get(_ATTEMPTED))
1320
+ return (taken, True) if isinstance(taken, dict) else None
1321
+ if record_type in (_ACTION_STEP, _ACT_RECORD_TYPE) or every_record_is_an_act:
1322
+ return body, False
1323
+ return None
1324
+
1325
+
1326
+ def _executed_spend(body: dict, *, unchecked: bool, accepted: bool) -> int | None:
1327
+ """The spend the act ``body`` states took, or ``None`` when it cannot be
1328
+ read. Money moved in (``MONEY_IN``) is ``0``, decided or not, and a
1329
+ disclosure moves none. An accepted act is counted at its
1330
+ ``amount_minor``, as the fold counts an accepted decision. An act taken
1331
+ without a check is sealed before its spend is classed, so its
1332
+ ``amount_minor`` stands in for a missing ``spend_minor``. Any other act
1333
+ is counted at its ``spend_minor``, which must be an integer of at least
1334
+ 0."""
1335
+ if body.get("direction") == MONEY_IN or _DISCLOSED in body:
1336
+ return 0
1337
+ if accepted:
1338
+ return _minor(body.get("amount_minor"))
1339
+ spend = _minor(body.get("spend_minor"))
1340
+ if unchecked:
1341
+ return spend if spend is not None else _minor(body.get("amount_minor"))
1342
+ return spend if spend is not None and spend >= 0 else None
1343
+
1344
+
1345
+ def _executed_act(capsule: dict, shown: dict | None, *, every_record_is_an_act: bool = False) -> _ExecutedAct | None:
1346
+ """The act the record ``shown``, bound by ``capsule``, states was
1347
+ executed (``_taken``), or ``None`` for one that states none. An act whose
1348
+ record is not given cannot be read: its spend is ``None``."""
1349
+ if shown is None:
1350
+ return _ExecutedAct(spend_minor=None, action_class=None)
1351
+ taken = _taken(shown, every_record_is_an_act=every_record_is_an_act)
1352
+ if taken is None:
1353
+ return None
1354
+ body, unchecked = taken
1355
+ spend = _executed_spend(body, unchecked=unchecked, accepted=_sealed_accept(capsule))
1356
+ return _ExecutedAct(spend_minor=spend, action_class=_text(body.get("action_class")))
1357
+
1358
+
1359
+ def _sealed_accept(capsule: dict) -> bool:
1360
+ disposition = capsule.get("disposition")
1361
+ return isinstance(disposition, dict) and disposition.get("decision") == _ACCEPT
1362
+
1363
+
1364
+ def _act_ids(capsule: dict) -> frozenset[str]:
1365
+ """The identities an act's capsule carries: its ``capsule_id`` and
1366
+ ``action_id``."""
1367
+ return frozenset(v for v in (capsule.get("capsule_id"), capsule.get("action_id")) if isinstance(v, str) and v)
1368
+
1369
+
1370
+ class ExecutedActs:
1371
+ """Each executed act counted once in the spend window. An accepted act
1372
+ always counts. Any other act that shares a ``capsule_id`` or an
1373
+ ``action_id`` with an accepted one, or with an act already counted, is the
1374
+ same act and adds nothing. ``capsules`` are every act's capsule the
1375
+ window may hold, so the accepted ones are known whatever their order."""
1376
+
1377
+ def __init__(self, capsules: Sequence[dict]) -> None:
1378
+ self._accepted = frozenset().union(*(_act_ids(c) for c in capsules if _sealed_accept(c)))
1379
+ self._counted: set[str] = set()
1380
+
1381
+ def record(self, capsule: dict, shown: dict | None, act_digest: str, *,
1382
+ every_record_is_an_act: bool = False) -> _ExecutedRecord | None:
1383
+ """The spend window's record of the act ``capsule`` binds
1384
+ (``_executed_record``), or ``None`` when it states no executed act
1385
+ or was counted already."""
1386
+ act = _executed_act(capsule, shown, every_record_is_an_act=every_record_is_an_act)
1387
+ if act is None:
1388
+ return None
1389
+ ids = _act_ids(capsule)
1390
+ if not _sealed_accept(capsule):
1391
+ if ids & (self._accepted | self._counted):
1392
+ return None
1393
+ self._counted |= ids
1394
+ return _executed_record(capsule, act, act_digest)
1395
+
1396
+
1397
+ class _ExecutedCitation(TypedDict):
1398
+ act: str
1399
+
1400
+
1401
+ class _ExecutedPayload(TypedDict, total=False):
1402
+ amount_minor: int
1403
+ action_class: str
1404
+ spend_unreadable: bool
1405
+ executed: _ExecutedCitation
1406
+
1407
+
1408
+ class _ExecutedBody(TypedDict):
1409
+ operator: str | None
1410
+ timestamp: str | None
1411
+ disposition: _Disposition
1412
+ asg_payload: _ExecutedPayload
1413
+
1414
+
1415
+ class _ExecutedRecord(_ExecutedBody):
1416
+ capsule_id: str
1417
+
1418
+
1419
+ def _executed_record(capsule: dict, act: _ExecutedAct, act_digest: str) -> _ExecutedRecord:
1420
+ """The record of an executed act for the spend window
1421
+ (``spend.weekly/3.1.0``), under its capsule's operator and time, citing
1422
+ the act's record by digest; its ``capsule_id`` is the digest of the rest.
1423
+ It carries the act's spend as ``amount_minor``, or ``spend_unreadable``
1424
+ and no amount when the spend cannot be read (``_executed_spend``), so
1425
+ the window counts the act or is known not to. It is never a decision
1426
+ and names no target, so it is never seen, and never the earlier act a
1427
+ repeat duplicates."""
1428
+ payload = _ExecutedPayload(executed=_ExecutedCitation(act=act_digest))
1429
+ if act.spend_minor is None:
1430
+ payload[SPEND_UNREADABLE] = True
1431
+ else:
1432
+ payload["amount_minor"] = act.spend_minor
1433
+ if act.action_class is not None:
1434
+ payload["action_class"] = act.action_class
1435
+ body = _ExecutedBody(
1436
+ operator=_text(capsule.get("operator")),
1437
+ timestamp=_text(capsule.get("timestamp")),
1438
+ disposition=_Disposition(decision=EXECUTED_DECISION),
1439
+ asg_payload=payload,
1440
+ )
1441
+ return _ExecutedRecord(**body, capsule_id=json_digest(body))
1442
+
1443
+
1444
+ @dataclass(frozen=True)
1445
+ class SourcedDecision:
1446
+ """One replayed record, its resulting ``Action``, its real
1447
+ ``GuardDecision``, and (when a check matched a prior record) that prior
1448
+ record's full capsule -- fetched from the replay's own ephemeral store
1449
+ while it is still open, so the report can carry it for re-verification."""
1450
+
1451
+ record: dict
1452
+ action: Action
1453
+ decision: GuardDecision
1454
+ cited_capsule: dict | None
1455
+
1456
+
1457
+ @dataclass(frozen=True)
1458
+ class SaleCheck:
1459
+ """A replayed check is one of a sale's (``report/sale_bundle.py``):
1460
+ ``item_ref`` is the sale's key, ``None`` when it cannot be read."""
1461
+
1462
+ item_ref: str | None
1463
+
1464
+
1465
+ # Writes the history of the sale a replayed record is a check of into the
1466
+ # ledger it is given, and says so; ``None`` for a record of no such sale. The
1467
+ # third argument, when not ``None``, cuts the history to the acts sealed in
1468
+ # that many days before the check, as a live check's history is cut.
1469
+ SaleHistory = Callable[[str, LedgerAPI, "int | None"], "SaleCheck | None"]
1470
+
1471
+
1472
+ @dataclass(frozen=True)
1473
+ class ReplayResult:
1474
+ """``decisions`` are the records that state an act, in order;
1475
+ ``undecided`` the records the replay gave no decision
1476
+ (``_gets_no_decision``). ``record_range`` spans both."""
1477
+
1478
+ decisions: tuple[SourcedDecision, ...]
1479
+ record_range: tuple[int, int]
1480
+ undecided: tuple[dict, ...] = ()
1481
+
1482
+
1483
+ def replay(
1484
+ records: list[dict],
1485
+ *,
1486
+ caps_fold: FoldDefinition | None,
1487
+ caps_minor: dict[str, int] | None = None,
1488
+ manifest_digest: str | None = None,
1489
+ per_action_minor: dict[str, int] | None = None,
1490
+ per_action_reads: str | None = None,
1491
+ disclosed: dict[str, dict] | None = None,
1492
+ withheld: frozenset[str] = frozenset(),
1493
+ wickets: tuple[WicketDefinition, ...] = (),
1494
+ pack: PackDefinition | None = None,
1495
+ sale_history: SaleHistory | None = None,
1496
+ representing_openings: Mapping[str, dict] | None = None,
1497
+ ) -> ReplayResult:
1498
+ """Feed every record through a fresh ``GuardEngine`` in dry-run mode, in
1499
+ order. Never blocks (``dry_run=True``) -- see ``engine.py``'s own
1500
+ guarantee that a dry-run decision still produces and appends a real,
1501
+ signed capsule. ``record_range`` on the result is this replayed set's own
1502
+ 1-based position range (i.e. positions within the ``--since``-filtered
1503
+ window actually replayed, not the source ledger's absolute positions).
1504
+ ``wickets`` are configured checks run on every decision, as
1505
+ ``GuardEngine(wickets=...)`` runs them; none by default. ``pack`` is the
1506
+ pack those wickets were installed from: given, the engine asks an approver
1507
+ on the same failures ``packs.build_engine`` does (``engine_ask_sets``);
1508
+ without it every gate or wicket failure refuses.
1509
+ ``withheld`` are the records a bundle carries without disclosing
1510
+ (``load_withheld``); like every record that states no act, each gets no
1511
+ decision.
1512
+ When a deal's sealed records show that an act whose check this replay
1513
+ decided (allowed, or asked) was approved to proceed, by the user or under
1514
+ the user's standing intent, and then executed (``_carried_out``), the replay writes that act to its own view as
1515
+ carried out (``_carried_out_record``), once, when it reaches the action
1516
+ step, so a later check counts it as an earlier act with that
1517
+ counterparty. A refused check is never counted.
1518
+ Under a caps fold that counts executed acts (``spend.weekly/3.1.0``),
1519
+ every act a deal executed is also written to the view when the replay
1520
+ reaches it (``_executed_record``), whoever approved it and whether or not
1521
+ it was checked, so a later check's spend window counts it once. The
1522
+ replay's own decisions are dry runs and never count, so no act counts
1523
+ twice. Under any other fold nothing more is written, and a replay keeps
1524
+ the bytes it had.
1525
+ A claim the user's agent made to the counterparty is written to that view
1526
+ as a statement (``statement_record``) when the replay reaches it, so a
1527
+ later check in the same deal finds it made first. A claim the sources
1528
+ give more than once is written once.
1529
+ ``representing_openings`` are the openings of represent checks' words
1530
+ the bundles' deal reports carry, by commitment
1531
+ (``load_representing_openings``); a check whose sealed commitment one
1532
+ recomputes is decided with it, as a live check is with its words.
1533
+ A check citing a task authority (``task_authority_ref``) is decided with
1534
+ the task-authority record the bundle discloses under that digest, when
1535
+ it discloses one (``_decide``), as a live check gets it beside its input.
1536
+ A seller's typed commit is decided with ``proposal_at`` the sealed time
1537
+ of the accepted offer it rests on, and a typed offer with the time of the
1538
+ counterparty's message it answers by its ``source`` ref
1539
+ (``_accepted_offer_at``), so ``offer_expiry`` reads that proposal's age.
1540
+ Each record is evaluated under the action taxonomy it was sealed with,
1541
+ where the engine carries that version, so history at a carried version is
1542
+ never refused for its version. A record naming one it does not carry
1543
+ still gets a decision: its class-keyed checks are held ``n/a`` with
1544
+ evidence naming both versions, and when nothing else fails it is refused
1545
+ (``GuardEngine``), so no later check counts it as seen or as spend.
1546
+ ``sale_history`` is given for a replay of a sale bundle
1547
+ (``report/sale_bundle.py``, ``replay_sale``). A check it says is the
1548
+ sale's is decided with the sale's key as ``item_ref``, and
1549
+ ``single_commitment`` reads the sale's acceptance from a fresh ledger it
1550
+ writes for that check alone (``GuardEngine.check``'s ``sale_history``),
1551
+ never from the replay's own view. A ``release_on_acceptance`` that binds
1552
+ the acceptance from the history (1.2.0) reads it too, cut to the acts of
1553
+ the last ``RULES_HISTORY_DAYS`` days before the check, as live
1554
+ (``GuardEngine.check``'s ``release_history``); within that window the
1555
+ same completeness rule holds."""
1556
+ if not records:
1557
+ return ReplayResult(decisions=(), record_range=(0, -1))
1558
+
1559
+ signer = LocalSigner(key_id="dry-run-report", secret=b"asg-guard-dry-run-report")
1560
+
1561
+ gate_selectors, ask_checks = engine_ask_sets(pack, wickets) if pack is not None else (frozenset(), frozenset())
1562
+ sourced: list[SourcedDecision] = []
1563
+ undecided: list[dict] = []
1564
+ with tempfile.TemporaryDirectory() as tmp, LedgerStore(tmp) as store:
1565
+ engine = GuardEngine(
1566
+ ledger=store,
1567
+ caps_fold=caps_fold,
1568
+ signer_provider=lambda: signer,
1569
+ caps_minor=caps_minor or {},
1570
+ per_action_minor=per_action_minor,
1571
+ per_action_reads=per_action_reads,
1572
+ manifest_digest=manifest_digest,
1573
+ wickets=wickets,
1574
+ ask_gate_selectors=gate_selectors,
1575
+ ask_wickets=ask_checks,
1576
+ evaluate_under_record_taxonomy=True,
1577
+ )
1578
+ profiles = _companion_profiles(records, disclosed or {})
1579
+ deal = _deal_records(records, disclosed or {})
1580
+ offered_at = _accepted_offer_at(records, disclosed or {})
1581
+ authorities = _task_authorities(disclosed or {})
1582
+ decided_checks: dict[str, SourcedDecision] = {}
1583
+ counts_spend = counts_executed_acts(caps_fold)
1584
+ executed_acts = ExecutedActs(records)
1585
+ written_statements: set[str] = set()
1586
+ for record in records:
1587
+ shown = (disclosed or {}).get(record.get("capsule_id", ""))
1588
+ digest = json_digest(shown) if shown is not None else None
1589
+ if _gets_no_decision(record, shown, withheld):
1590
+ undecided.append(record)
1591
+ stated = statement_record(record, shown)
1592
+ if stated is not None and claim_of(stated) not in written_statements:
1593
+ # A claim given twice (overlapping sources) is one statement.
1594
+ written_statements.add(claim_of(stated))
1595
+ store.append(dict(stated), consequential=False)
1596
+ chain = _carried_out(digest, deal) if digest is not None else None
1597
+ checked = decided_checks.pop(chain.check, None) if chain is not None else None
1598
+ if chain is not None and checked is not None and checked.decision.outcome in (ALLOW, ESCALATE):
1599
+ store.append(dict(_carried_out_record(checked, chain, _text(record.get("timestamp")))), consequential=False)
1600
+ if counts_spend and digest is not None and _bound(record, shown):
1601
+ executed = executed_acts.record(record, shown, digest)
1602
+ if executed is not None:
1603
+ store.append(dict(executed), consequential=False)
1604
+ continue
1605
+ profile = profiles.get(digest) if profiles and digest is not None else None
1606
+ action = action_for_record(record, shown, counterparty_profile=profile)
1607
+ if digest in offered_at:
1608
+ action = replace(action, proposal_at=offered_at[digest])
1609
+ authority = authorities.get(action.task_authority_ref) if action.task_authority_ref else None
1610
+ opening = (representing_openings or {}).get(action.representation_text_commitment or "")
1611
+ action, decision = _decide(engine, action, record, sale_history, authority, opening)
1612
+ cited_capsule = None
1613
+ for constraint in decision.constraints:
1614
+ cited_id = (constraint.evidence or {}).get("matched_capsule_id") or (
1615
+ constraint.evidence or {}
1616
+ ).get("cited_capsule_id")
1617
+ if cited_id:
1618
+ found = store.fetch(cited_id)
1619
+ if found is not None:
1620
+ cited_capsule = found.capsule
1621
+ break
1622
+ sourced.append(SourcedDecision(record=record, action=action, decision=decision, cited_capsule=cited_capsule))
1623
+ if digest in deal and decision.capsule is not None:
1624
+ decided_checks[digest] = sourced[-1]
1625
+
1626
+ return ReplayResult(decisions=tuple(sourced), record_range=(1, len(records)), undecided=tuple(undecided))
1627
+
1628
+
1629
+ def _task_authorities(disclosed: dict[str, dict]) -> dict[str, dict]:
1630
+ """The task-authority records a bundle discloses (``task-authority/v0``),
1631
+ by the SHA-256 digest of each, the digest a check's
1632
+ ``task_authority_ref`` names. A record with no digest (a float, an
1633
+ unsafe integer) is left out: no reference can name it."""
1634
+ found: dict[str, dict] = {}
1635
+ for shown in disclosed.values():
1636
+ if isinstance(shown, dict) and shown.get("type") == _TASK_AUTHORITY:
1637
+ try:
1638
+ found[json_digest(shown)] = shown
1639
+ except (FloatInDigestError, UnsafeIntegerError, UnicodeEncodeError):
1640
+ continue
1641
+ return found
1642
+
1643
+
1644
+ def _decide(engine: GuardEngine, action: Action, record: dict, sale_history: SaleHistory | None,
1645
+ task_authority: dict | None = None, opening: dict | None = None) -> tuple[Action, GuardDecision]:
1646
+ """The dry-run decision on ``action``, and the action decided: with the
1647
+ sale's key and the sale's history for a check of a replayed sale
1648
+ (``replay``), as given otherwise. ``task_authority`` is the record the
1649
+ bundle discloses under the digest the action's ``task_authority_ref``
1650
+ names (``_task_authorities``); the engine reads it only once its own
1651
+ digest of it equals that reference, as live, so a missing record leaves
1652
+ it unread (capsule-engine#180). ``opening`` is the opening of the
1653
+ represent check's words the bundle's deal report carries
1654
+ (``representing_openings``), passed as ``representing_opening`` so
1655
+ ``promise_never`` judges them as live; with none,
1656
+ ``WORDS_NOT_IN_BUNDLE``: a claim attributed to the user is then not
1657
+ evaluable ("the words for this attributed claim are not in the bundle"),
1658
+ never a failure (``guards/checks/promise_never.py``)."""
1659
+ capsule_id = _text(record.get("capsule_id"))
1660
+
1661
+ def check(decided: Action, **ledgers: LedgerAPI) -> GuardDecision:
1662
+ return engine.check(decided, dry_run=True, task_authority_record=task_authority,
1663
+ representing_opening=opening if opening is not None else WORDS_NOT_IN_BUNDLE,
1664
+ **ledgers)
1665
+
1666
+ if sale_history is None or capsule_id is None:
1667
+ return action, check(action)
1668
+ with tempfile.TemporaryDirectory() as tmp, LedgerStore(tmp) as sale_ledger:
1669
+ sale = sale_history(capsule_id, sale_ledger, None)
1670
+ if sale is None:
1671
+ return action, check(action)
1672
+ action = replace(action, item_ref=sale.item_ref)
1673
+ if not engine.uses_release_history:
1674
+ return action, check(action, sale_history=sale_ledger)
1675
+ with tempfile.TemporaryDirectory() as window_tmp, LedgerStore(window_tmp) as window_ledger:
1676
+ sale_history(capsule_id, window_ledger, RULES_HISTORY_DAYS)
1677
+ return action, check(action, sale_history=sale_ledger, release_history=window_ledger)